Add customer matching filters exports and diagnostics UI

This commit is contained in:
mfwadejr
2026-08-30 16:16:17 -04:00
committed by GitHub
parent 4cd2861e01
commit 132c180a39
4 changed files with 16 additions and 9 deletions
+13 -8
View File
@@ -1,5 +1,5 @@
const $ = (s) => document.querySelector(s);
const state = { products: [], models: [], warranties: [], tab: "available", query: "", page: 1, user: null };
const state = { products: [], models: [], warranties: [], customerDirectory: [], tab: "available", query: "", page: 1, user: null, filters:{model:"",payment:"",fulfillment:"",warranty:"",from:"",to:""} };
const PAGE_SIZE = 10;
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const today = () => new Date().toISOString().slice(0, 10);
@@ -32,8 +32,10 @@ function warrantyBadge(p){const w=warrantyState(p);return `<span class="warranty
function trackingUrl(p){if(!p.trackingNumber)return"";const n=encodeURIComponent(p.trackingNumber);return p.carrier==="UPS"?`https://www.ups.com/track?tracknum=${n}`:p.carrier==="FedEx"?`https://www.fedex.com/fedextrack/?trknbr=${n}`:p.carrier==="USPS"?`https://tools.usps.com/go/TrackConfirmAction?tLabels=${n}`:"";}
const fulfillmentTitle=method=>({Shipped:"Shipped to","Dropped Off":"Dropped off","Installed At":"Installed at",Meet:"Meet"}[method]||"Fulfillment");
function warrantyOptions(selected=""){return state.warranties.map(w=>`<option value="${w.id}" ${(selected?w.id===selected:w.isDefault)?"selected":""}>${esc(w.name)}</option>`).join("");}
function fulfillmentFields(method="",p={}){if(!method)return'<p class="conditional-hint">Select a delivery method to enter its details.</p>';const addressRequired=new Set(["Shipped","Installed At"]).has(method),casual=new Set(["Dropped Off","Meet"]).has(method),nameLabel=method==="Meet"?"Meeting place / venue":method==="Dropped Off"?"Drop-off location / venue":"Location name (optional)",noteLabel=method==="Meet"?"Meet details":method==="Dropped Off"?"Drop-off details":"Fulfillment notes";return `<div class="conditional-fields"><h3>${fulfillmentTitle(method)}</h3>${method!=="Shipped"?`<label>${nameLabel}<input name="fulfillmentName" value="${esc(p.fulfillmentName||"")}" ${casual?"":""} placeholder="${method==="Meet"?"Example: Wawa on Main Street":method==="Dropped Off"?"Example: Exxon station":"Business or residence"}"></label>`:""}<label>Street address${addressRequired?"":" (optional)"}<input name="shipAddress1" value="${esc(p.shipAddress1||"")}" ${addressRequired?"required":""} autocomplete="shipping address-line1"></label><label>Apartment, suite, or unit<input name="shipAddress2" value="${esc(p.shipAddress2||"")}" autocomplete="shipping address-line2"></label><div class="address-grid"><label>City<input name="shipCity" value="${esc(p.shipCity||"")}" ${addressRequired?"required":""} autocomplete="shipping address-level2"></label><label>State<input name="shipState" value="${esc(p.shipState||"")}" ${addressRequired?"required":""} autocomplete="shipping address-level1"></label><label>ZIP code<input name="shipZip" value="${esc(p.shipZip||"")}" ${addressRequired?"required":""} autocomplete="shipping postal-code"></label></div>${method==="Shipped"?`<div class="form-row"><label>Carrier<select name="carrier" required><option value="">Choose carrier</option>${["UPS","FedEx","USPS","Other"].map(x=>`<option ${p.carrier===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Tracking number (optional)<input name="trackingNumber" value="${esc(p.trackingNumber||"")}"></label></div><label>Delivery status<select name="deliveryStatus">${["Awaiting Tracking","Label Created","In Transit","Out for Delivery","Delivered","Exception","Returned"].map(x=>`<option ${p.deliveryStatus===x?"selected":""}>${x}</option>`).join("")}</select></label>`:""}<label>${noteLabel}<textarea name="fulfillmentNotes" rows="2" placeholder="${casual?"Add a venue, address, or enough detail to identify where the handoff occurred":"Optional delivery or installation details"}">${esc(p.fulfillmentNotes||p.shippingNotes||"")}</textarea></label>${casual?'<small class="field-help">Enter at least a venue, address, or detail.</small>':""}</div>`;}
function fulfillmentFields(method="",p={}){if(!method)return'<p class="conditional-hint">Select a delivery method to enter its details.</p>';const addressRequired=new Set(["Shipped","Installed At"]).has(method),casual=new Set(["Dropped Off","Meet"]).has(method),nameLabel=method==="Meet"?"Meeting place / venue":method==="Dropped Off"?"Drop-off location / venue":"Location name (optional)",noteLabel=method==="Meet"?"Meet details":method==="Dropped Off"?"Drop-off details":"Fulfillment notes";return `<div class="conditional-fields"><h3>${fulfillmentTitle(method)}</h3>${method!=="Shipped"?`<label>${nameLabel}<input name="fulfillmentName" value="${esc(p.fulfillmentName||"")}" placeholder="${method==="Meet"?"Example: Wawa on Main Street":method==="Dropped Off"?"Example: Exxon station":"Business or residence"}"></label>`:""}<label>Street address${addressRequired?"":" (optional)"}<input name="shipAddress1" value="${esc(p.shipAddress1||"")}" ${addressRequired?"required":""} autocomplete="shipping address-line1"></label><label>Apartment, suite, or unit<input name="shipAddress2" value="${esc(p.shipAddress2||"")}" autocomplete="shipping address-line2"></label><div class="address-grid"><label>City<input name="shipCity" value="${esc(p.shipCity||"")}" ${addressRequired?"required":""} autocomplete="shipping address-level2"></label><label>State<input name="shipState" value="${esc(p.shipState||"")}" ${addressRequired?"required":""} autocomplete="shipping address-level1"></label><label>ZIP code<input name="shipZip" value="${esc(p.shipZip||"")}" ${addressRequired?"required":""} autocomplete="shipping postal-code"></label></div>${method==="Shipped"?`<div class="form-row"><label>Carrier<select name="carrier" required><option value="">Choose carrier</option>${["UPS","FedEx","USPS","Other"].map(x=>`<option ${p.carrier===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Tracking number (optional)<input name="trackingNumber" value="${esc(p.trackingNumber||"")}"></label></div><label>Delivery status<select name="deliveryStatus">${["Awaiting Tracking","Label Created","In Transit","Out for Delivery","Delivered","Delivery Exception","Returned","Unknown"].map(x=>`<option ${p.deliveryStatus===x?"selected":""}>${x}</option>`).join("")}</select></label>`:""}<label>${noteLabel}<textarea name="fulfillmentNotes" rows="2" placeholder="${casual?"Add a venue, address, or enough detail to identify where the handoff occurred":"Optional delivery or installation details"}">${esc(p.fulfillmentNotes||p.shippingNotes||"")}</textarea></label>${casual?'<small class="field-help">Enter at least a venue, address, or detail.</small>':""}</div>`;}
function bindFulfillment(form,p={}){const select=form.querySelector('[name="fulfillmentMethod"]'),target=form.querySelector(".fulfillment-fields");const draw=()=>target.innerHTML=fulfillmentFields(select.value,p);select.onchange=()=>{p={};draw();};draw();}
function customerFields(p={}){return `<input type="hidden" name="customerId" value="${esc(p.customerId||"")}"><div class="form-row"><label>Customer name<input name="customerName" list="customerChoices" value="${esc(p.customerName||"")}" autocomplete="off" required><datalist id="customerChoices">${state.customerDirectory.map(c=>`<option value="${esc(c.name)}">${esc(c.phone||"No phone")}</option>`).join("")}</datalist></label><label>Cell phone<input name="phone" type="tel" value="${esc(p.phone||"")}" autocomplete="tel"></label></div><p class="customer-match" aria-live="polite"></p>`}
function bindCustomer(form){const name=form.elements.customerName,phone=form.elements.phone,id=form.elements.customerId,status=form.querySelector(".customer-match");const match=()=>{const phoneDigits=phone.value.replace(/\D/g,"").slice(-10),found=state.customerDirectory.find(c=>phoneDigits&&c.phone.replace(/\D/g,"").slice(-10)===phoneDigits)||state.customerDirectory.find(c=>c.name.toLowerCase()===name.value.trim().toLowerCase());if(found){id.value=found.id;name.value=found.name;if(!phone.value)phone.value=found.phone;status.textContent=`Existing customer selected${found.phone?` · ${found.phone}`:""}.`;}else{id.value="";status.textContent=name.value?"A new customer will be created.":"";}};name.onchange=match;name.oninput=()=>{id.value="";status.textContent=""};phone.onchange=match;match();}
function idCell(p) { return ids(p).length ? ids(p).map(([k,v])=>`<code>${k}: ${esc(v)}</code>`).join("") : "<small>Not entered</small>"; }
function customers() {
const grouped=new Map();
@@ -48,8 +50,9 @@ function filtered() {
const q=state.query.toLowerCase().trim();
if(state.tab==="customers") return customers().filter(c=>!q||[c.name,c.phone].some(v=>String(v||"").toLowerCase().includes(q))||c.purchases.some(p=>Object.values(p).some(v=>String(v??"").toLowerCase().includes(q))));
const status=state.tab==="available"?"available":"sold";
return state.products.filter(p=>p.status===status && (!q || Object.values(p).some(v=>String(v??"").toLowerCase().includes(q))));
return state.products.filter(p=>p.status===status&&(!q||Object.values(p).some(v=>String(v??"").toLowerCase().includes(q)))&&(!state.filters.model||p.model===state.filters.model)&&(!state.filters.payment||p.paymentMethod===state.filters.payment)&&(!state.filters.fulfillment||p.fulfillmentMethod===state.filters.fulfillment)&&(!state.filters.from||(p.soldAt||p.receivedAt)>=state.filters.from)&&(!state.filters.to||(p.soldAt||p.receivedAt)<=state.filters.to)&&(!state.filters.warranty||(state.filters.warranty==="active"&&warrantyState(p).className==="in-warranty")||(state.filters.warranty==="expired"&&warrantyState(p).className==="expired")||(state.filters.warranty==="none"&&warrantyState(p).className==="neutral")));
}
function renderFilters(){const bar=$("#filterBar");if(state.tab==="admin"){bar.innerHTML="";bar.hidden=true;return}if(state.tab==="customers"){bar.hidden=false;bar.innerHTML='<a class="button secondary export-button" href="/api/export/customers">Export customers CSV</a>';return}bar.hidden=false;const models=[...new Set(state.products.map(p=>p.model))].sort();bar.innerHTML=`<label>Model<select data-filter="model"><option value="">All models</option>${models.map(x=>`<option ${state.filters.model===x?"selected":""}>${esc(x)}</option>`).join("")}</select></label>${state.tab==="sold"?`<label>Payment<select data-filter="payment"><option value="">All payments</option>${["Cash","Venmo","PayPal"].map(x=>`<option ${state.filters.payment===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Fulfillment<select data-filter="fulfillment"><option value="">All methods</option>${["Shipped","Dropped Off","Installed At","Meet"].map(x=>`<option ${state.filters.fulfillment===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Warranty<select data-filter="warranty"><option value="">All warranties</option><option value="active" ${state.filters.warranty==="active"?"selected":""}>In Warranty</option><option value="expired" ${state.filters.warranty==="expired"?"selected":""}>Expired</option><option value="none" ${state.filters.warranty==="none"?"selected":""}>None / Not recorded</option></select></label>`:""}<label>From<input type="date" data-filter="from" value="${state.filters.from}"></label><label>To<input type="date" data-filter="to" value="${state.filters.to}"></label><button class="secondary" id="clearFilters">Clear</button><a class="button secondary export-button" href="/api/export/${state.tab==="sold"?"sales":"inventory"}">Export CSV</a>`;bar.querySelectorAll("[data-filter]").forEach(el=>el.onchange=()=>{state.filters[el.dataset.filter]=el.value;state.page=1;render()});$("#clearFilters").onclick=()=>{state.filters={model:"",payment:"",fulfillment:"",warranty:"",from:"",to:""};state.page=1;render()}}
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;
@@ -57,6 +60,7 @@ function render() {
$("#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; $("#customerBadge").textContent=customers().length;
document.querySelectorAll("[data-tab]").forEach(b=>b.classList.toggle("active",b.dataset.tab===state.tab));
renderFilters();
if(state.tab==="admin") { $(".table-wrap").hidden=true; $("#adminPanel").hidden=false; $("#pagination").innerHTML=""; renderAdmin(); return; }
$(".table-wrap").hidden=false; $("#adminPanel").hidden=true;
const all=filtered(), pages=Math.max(1,Math.ceil(all.length/PAGE_SIZE)); state.page=Math.min(state.page,pages);
@@ -78,9 +82,9 @@ function receiveForm() {
}
function sellForm(id="") {
const available=state.products.filter(p=>p.status==="available");
openModal(`<h2>Record a sale</h2><p>Enter the customer, payment, fulfillment, and warranty 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><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-row"><label>Delivery method<select name="fulfillmentMethod" required><option value="">Choose a method</option><option>Shipped</option><option>Dropped Off</option><option>Installed At</option><option>Meet</option></select></label><label>Warranty<select name="warrantyPresetId" required>${warrantyOptions()}</select></label></div><div class="fulfillment-fields"></div><label>Transaction notes (optional)<textarea name="saleNotes" rows="3" placeholder="Additional notes 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, fulfillment, and warranty 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>${customerFields()}<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><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-row"><label>Delivery method<select name="fulfillmentMethod" required><option value="">Choose a method</option><option>Shipped</option><option>Dropped Off</option><option>Installed At</option><option>Meet</option></select></label><label>Warranty<select name="warrantyPresetId" required>${warrantyOptions()}</select></label></div><div class="fulfillment-fields"></div><label>Transaction notes (optional)<textarea name="saleNotes" rows="3" placeholder="Additional notes 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>`);
bindFulfillment($("#sellForm"));
$("#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;
bindCustomer($("#sellForm"));$("#sellForm").onsubmit=async e=>{ e.preventDefault();const button=e.submitter;button.disabled=true;const data=Object.fromEntries(new FormData(e.currentTarget)), productId=data.productId;delete data.productId;try{await change(`/api/products/${encodeURIComponent(productId)}/sell`,"POST",data,"Sale recorded.");}finally{button.disabled=false} }; $("[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 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); } }
@@ -89,7 +93,7 @@ function viewSale(p) {
const link=trackingUrl(p),fulfillment=p.fulfillmentMethod?`<section class="detail-card"><h3>${fulfillmentTitle(p.fulfillmentMethod)}</h3>${p.fulfillmentName?`<p><strong>${esc(p.fulfillmentName)}</strong></p>`:""}<p>${address(p)?esc(address(p)):"No street address recorded"}</p>${p.fulfillmentNotes?`<p>${esc(p.fulfillmentNotes)}</p>`:""}${p.fulfillmentMethod==="Shipped"?`<p>Carrier: <strong>${esc(p.carrier||"Not recorded")}</strong></p><p>Tracking: ${p.trackingNumber?(link?`<a href="${link}" target="_blank" rel="noopener">${esc(p.trackingNumber)}</a>`:esc(p.trackingNumber)):"Not entered"}</p><p>Delivery status: <strong>${esc(p.deliveryStatus||"Not recorded")}</strong></p>`:""}</section>`:'<section class="detail-card"><h3>Fulfillment</h3><p>Not recorded</p></section>';
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 and sale</h3><p><strong>${esc(p.paymentMethod||"Not recorded")}</strong>${p.paymentReference?` · Reference: ${esc(p.paymentReference)}`:""}</p><p>Sale price: ${p.salePrice?money.format(p.salePrice):"—"} · Sold: ${fmtDate(p.soldAt)}</p></section>${fulfillment}<section class="detail-card"><h3>Warranty</h3><p>${warrantyBadge(p)}</p><p>${p.warrantyName?`${esc(p.warrantyName)}${p.warrantyEndDate?` · Through ${fmtDate(p.warrantyEndDate)}`:""}`:"No warranty was recorded for this sale."}</p></section><section class="detail-card"><h3>Product</h3><p>${esc(p.manufacturer)} ${esc(p.model)} · ${esc(p.condition)}</p><div class="ids">${idCell(p)}</div><p>Received: ${fmtDate(p.receivedAt)} · Cost: ${p.cost?money.format(p.cost):"—"}</p>${p.notes?`<p>Inventory notes: ${esc(p.notes)}</p>`:""}</section><section class="detail-card"><h3>Transaction notes</h3><p>${esc(p.saleNotes||"No transaction notes recorded")}</p></section><div class="form-actions"><button type="button" class="secondary" data-cancel>Close</button>${state.user.role==="admin"?'<button class="primary" id="editSaleDetails">Edit sale details</button>':""}</div>`); $("[data-cancel]").onclick=closeModal;if($("#editSaleDetails"))$("#editSaleDetails").onclick=()=>editSaleForm(p);
}
function editSaleForm(p){const historicalWarranty=p.warrantyPresetId&&!state.warranties.some(w=>w.id===p.warrantyPresetId)?`<option value="${p.warrantyPresetId}" selected>${esc(p.warrantyName)} (Archived)</option>`:"";openModal(`<h2>Edit sale details</h2><p>Update fulfillment, tracking, warranty, or transaction notes.</p><form id="editSaleForm"><div class="form-row"><label>Delivery method<select name="fulfillmentMethod" required><option value="">Choose a method</option>${["Shipped","Dropped Off","Installed At","Meet"].map(x=>`<option ${p.fulfillmentMethod===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Warranty<select name="warrantyPresetId" required>${historicalWarranty}${warrantyOptions(p.warrantyPresetId)}</select></label></div><div class="fulfillment-fields"></div><label>Transaction notes<textarea name="saleNotes" rows="3">${esc(p.saleNotes||"")}</textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Save changes</button></div></form>`);bindFulfillment($("#editSaleForm"),p);$("[data-cancel]").onclick=()=>viewSale(p);$("#editSaleForm").onsubmit=async e=>{e.preventDefault();try{const updated=await api(`/api/products/${encodeURIComponent(p.id)}`,{method:"PATCH",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await load();toast("Sale details updated.");viewSale(updated);}catch(error){toast(error.message);}};}
function editSaleForm(p){const historicalWarranty=p.warrantyPresetId&&!state.warranties.some(w=>w.id===p.warrantyPresetId)?`<option value="${p.warrantyPresetId}" selected>${esc(p.warrantyName)} (Archived)</option>`:"";openModal(`<h2>Edit sale details</h2><p>Correct customer, payment, fulfillment, tracking, warranty, or notes.</p><form id="editSaleForm">${customerFields(p)}<div class="form-row"><label>Payment method<select name="paymentMethod" required>${["Cash","Venmo","PayPal"].map(x=>`<option ${p.paymentMethod===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Payment reference<input name="paymentReference" value="${esc(p.paymentReference||"")}"></label></div><div class="form-row"><label>Sale price<input name="salePrice" type="number" min="0" step=".01" value="${Number(p.salePrice||0)}"></label><label>Date sold<input name="soldAt" type="date" value="${esc(p.soldAt||today())}" required></label></div><div class="form-row"><label>Delivery method<select name="fulfillmentMethod" required><option value="">Choose a method</option>${["Shipped","Dropped Off","Installed At","Meet"].map(x=>`<option ${p.fulfillmentMethod===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Warranty<select name="warrantyPresetId" required>${historicalWarranty}${warrantyOptions(p.warrantyPresetId)}</select></label></div><div class="fulfillment-fields"></div><label>Transaction notes<textarea name="saleNotes" rows="3">${esc(p.saleNotes||"")}</textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Save changes</button></div></form>`);bindFulfillment($("#editSaleForm"),p);bindCustomer($("#editSaleForm"));$("[data-cancel]").onclick=()=>viewSale(p);$("#editSaleForm").onsubmit=async e=>{e.preventDefault();const button=e.submitter;button.disabled=true;try{const updated=await api(`/api/products/${encodeURIComponent(p.id)}`,{method:"PATCH",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await load();toast("Sale details updated.");viewSale(updated);}catch(error){toast(error.message);}finally{button.disabled=false}};}
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(" · ");
@@ -102,11 +106,12 @@ async function viewCustomer(p) {
}
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."); }
async function load(){ [state.products,state.models,state.warranties]=await Promise.all([api("/api/products"),api("/api/models"),api("/api/warranties")]); render(); }
async function load(){ [state.products,state.models,state.warranties,state.customerDirectory]=await Promise.all([api("/api/products"),api("/api/models"),api("/api/warranties"),api("/api/customers")]); render(); }
async function renderAdmin(){
const panel=$("#adminPanel"); panel.innerHTML='<div class="admin-loading">Loading administration…</div>';
try{const [backups,users,audit]=await Promise.all([api("/api/admin/backups"),api("/api/admin/users"),api("/api/admin/audit")]);panel.innerHTML=`<div class="admin-page"><section class="admin-section"><div><h2>User accounts</h2><p>Create administrators or read-only accounts. New and reset passwords must be changed at next login.</p></div><form id="createUserForm" class="inline-user-form"><label>Username<input name="username" required minlength="3" autocomplete="off"></label><label>Temporary password<input name="password" type="password" required minlength="8" autocomplete="new-password"></label><label>Role<select name="role"><option value="readonly">Read-Only</option><option value="admin">Admin</option></select></label><button class="primary">Create user</button></form><div class="user-list">${users.map(u=>`<article><div><strong>${esc(u.username)}</strong><small>${u.role==="admin"?"Admin":"Read-Only"} · ${u.enabled?"Enabled":"Disabled"}${u.mustChangePassword?" · Password change required":""}${u.lastLoginAt?` · Last login ${fmtDateTime(u.lastLoginAt)}`:""}</small></div><div><button class="secondary" data-role-user="${u.id}" data-role="${u.role}">${u.role==="admin"?"Make Read-Only":"Make Admin"}</button><button class="secondary" data-reset-user="${u.id}" data-name="${esc(u.username)}">Reset password</button><button class="secondary" data-toggle-user="${u.id}" data-enabled="${u.enabled}">${u.enabled?"Disable":"Enable"}</button><button class="delete-backup" data-delete-user="${u.id}" data-name="${esc(u.username)}">Delete</button></div></article>`).join("")}</div></section><section class="admin-section"><div><h2>Database backups</h2><p>Create snapshots inside <code>/data/backups</code>, download an off-server copy, or restore a previous database.</p></div><div class="admin-actions"><button class="primary" id="createBackup">Create backup now</button><label class="upload-backup">Restore uploaded backup<input id="restoreUpload" type="file" accept=".db,application/vnd.sqlite3"></label></div><div class="backup-warning"><strong>Restore replaces the active database and all user accounts.</strong> Your current password is required. Everyone will be signed out afterward.</div><div class="backup-list">${backups.length?backups.map(b=>`<article><div><strong>${esc(b.name)}</strong><small>${fmtDateTime(b.createdAt)} · ${(b.size/1024).toFixed(1)} KB</small></div><div><a class="button secondary" href="/api/admin/backups/${encodeURIComponent(b.name)}/download">Download</a><button class="secondary" data-restore-backup="${esc(b.name)}">Restore</button><button class="delete-backup" data-delete-backup="${esc(b.name)}">Delete</button></div></article>`).join(""):"<p>No local backups yet.</p>"}</div></section><section class="admin-section"><div><h2>Audit log</h2><p>The latest 250 security and data-changing events. Audit entries cannot be edited or deleted.</p></div><div class="audit-list">${audit.map(a=>`<article><strong>${esc(a.username)}</strong><span>${esc(a.action.replaceAll("_"," "))}</span><small>${fmtDateTime(a.createdAt)}${a.target?` · ${esc(a.target)}`:""}${a.details?` · ${esc(a.details)}`:""}${a.ipAddress?` · ${esc(a.ipAddress)}`:""}</small></article>`).join("")||"<p>No audit events yet.</p>"}</div></section></div>`;
try{const [backups,users,audit,diagnostics]=await Promise.all([api("/api/admin/backups"),api("/api/admin/users"),api("/api/admin/audit"),api("/api/admin/diagnostics")]);panel.innerHTML=`<div class="admin-page"><section class="admin-section"><div><h2>User accounts</h2><p>Create administrators or read-only accounts. New and reset passwords must be changed at next login.</p></div><form id="createUserForm" class="inline-user-form"><label>Username<input name="username" required minlength="3" autocomplete="off"></label><label>Temporary password<input name="password" type="password" required minlength="8" autocomplete="new-password"></label><label>Role<select name="role"><option value="readonly">Read-Only</option><option value="admin">Admin</option></select></label><button class="primary">Create user</button></form><div class="user-list">${users.map(u=>`<article><div><strong>${esc(u.username)}</strong><small>${u.role==="admin"?"Admin":"Read-Only"} · ${u.enabled?"Enabled":"Disabled"}${u.mustChangePassword?" · Password change required":""}${u.lastLoginAt?` · Last login ${fmtDateTime(u.lastLoginAt)}`:""}</small></div><div><button class="secondary" data-role-user="${u.id}" data-role="${u.role}">${u.role==="admin"?"Make Read-Only":"Make Admin"}</button><button class="secondary" data-reset-user="${u.id}" data-name="${esc(u.username)}">Reset password</button><button class="secondary" data-toggle-user="${u.id}" data-enabled="${u.enabled}">${u.enabled?"Disable":"Enable"}</button><button class="delete-backup" data-delete-user="${u.id}" data-name="${esc(u.username)}">Delete</button></div></article>`).join("")}</div></section><section class="admin-section"><div><h2>Database backups</h2><p>Create snapshots inside <code>/data/backups</code>, download an off-server copy, or restore a previous database.</p></div><div class="admin-actions"><button class="primary" id="createBackup">Create backup now</button><label class="upload-backup">Restore uploaded backup<input id="restoreUpload" type="file" accept=".db,application/vnd.sqlite3"></label></div><div class="backup-warning"><strong>Restore replaces the active database and all user accounts.</strong> Your current password is required. Everyone will be signed out afterward.</div><div class="backup-list">${backups.length?backups.map(b=>`<article><div><strong>${esc(b.name)}</strong><small>${fmtDateTime(b.createdAt)} · ${(b.size/1024).toFixed(1)} KB</small></div><div><a class="button secondary" href="/api/admin/backups/${encodeURIComponent(b.name)}/download">Download</a><button class="secondary" data-restore-backup="${esc(b.name)}">Restore</button><button class="delete-backup" data-delete-backup="${esc(b.name)}">Delete</button></div></article>`).join(""):"<p>No local backups yet.</p>"}</div></section><section class="admin-section"><div><h2>Audit log</h2><p>The latest 250 security and data-changing events. Audit entries cannot be edited or deleted.</p></div><div class="audit-list">${audit.map(a=>`<article><strong>${esc(a.username)}</strong><span>${esc(a.action.replaceAll("_"," "))}</span><small>${fmtDateTime(a.createdAt)}${a.target?` · ${esc(a.target)}`:""}${a.details?` · ${esc(a.details)}`:""}${a.ipAddress?` · ${esc(a.ipAddress)}`:""}</small></article>`).join("")||"<p>No audit events yet.</p>"}</div></section></div>`;
panel.querySelector(".admin-page").insertAdjacentHTML("afterbegin",'<div class="admin-intro"><img src="/assets/vboxstock-icon-512.png" alt=""><div><span class="admin-kicker">vBoxStock</span><h2>Administration</h2><p>Manage product models, access, data protection, and account activity.</p></div></div>');
const bytes=n=>n>1073741824?`${(n/1073741824).toFixed(1)} GB`:n>1048576?`${(n/1048576).toFixed(1)} MB`:`${(n/1024).toFixed(1)} KB`,systemSection=document.createElement("section");systemSection.className="admin-section system-section";systemSection.innerHTML=`<div><h2>System and data tools</h2><p>vBoxStock ${esc(diagnostics.appVersion)} · Database schema ${diagnostics.schemaVersion} · ${esc(diagnostics.nodeVersion)}</p></div><div class="diagnostic-grid"><article><span>Database</span><strong>${bytes(diagnostics.databaseSize)}</strong><small>${diagnostics.dataWritable?"/data is writable":"/data is not writable"}</small></article><article><span>Storage available</span><strong>${bytes(diagnostics.diskFree)}</strong><small>of ${bytes(diagnostics.diskTotal)}</small></article><article><span>Backups</span><strong>${diagnostics.backupCount}</strong><small>${diagnostics.lastBackupAt?`Latest ${fmtDateTime(diagnostics.lastBackupAt)}`:"None created"}</small></article><article><span>Time zone</span><strong>${esc(diagnostics.timeZone)}</strong><small>${esc(diagnostics.dataDirectory)}</small></article></div><form id="backupScheduleForm" class="backup-schedule"><label class="check-label"><input name="enabled" type="checkbox" ${diagnostics.backupSettings.enabled?"checked":""}> Enable daily automatic backups</label><label>Backup hour (local time)<input name="hour" type="number" min="0" max="23" value="${diagnostics.backupSettings.hour}" required></label><label>Retain scheduled backups<input name="retention" type="number" min="1" max="365" value="${diagnostics.backupSettings.retention}" required></label><button class="primary">Save schedule</button></form><div class="export-links"><span>Download data:</span>${["inventory","sales","customers","warranties","audit"].map(x=>`<a class="button secondary" href="/api/export/${x}">${x[0].toUpperCase()+x.slice(1)} CSV</a>`).join("")}</div>`;panel.querySelector(".admin-intro").insertAdjacentElement("afterend",systemSection);$("#backupScheduleForm").onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.currentTarget));try{await api("/api/admin/backup-settings",{method:"PATCH",body:JSON.stringify({enabled:Boolean(data.enabled),hour:Number(data.hour),retention:Number(data.retention)})});toast("Backup schedule saved.");renderAdmin()}catch(error){toast(error.message)}};
const models=await api("/api/admin/models"),modelSection=document.createElement("section");modelSection.className="admin-section model-section";modelSection.innerHTML=`<div><h2>Product models</h2><p>Active models are available when receiving products. Archiving removes a model from new receiving while preserving inventory and sales history.</p></div><form id="createModelForm" class="inline-model-form"><label>Model name<input name="name" required maxlength="60" placeholder="Example: V7 Ultra" autocomplete="off"></label><button class="primary">Add model</button></form><div class="model-list">${models.map(m=>`<article><div class="model-summary"><div><strong>${esc(m.name)}</strong><span class="model-status ${m.active?"active":"archived"}">${m.active?"Active":"Archived"}</span></div><small>${m.availableCount} available · ${m.soldCount} sold</small></div><div class="model-actions">${m.totalCount===0?`<button class="secondary" data-rename-model="${m.id}" data-name="${esc(m.name)}">Rename</button>`:""}<button class="secondary" data-toggle-model="${m.id}" data-active="${m.active}" data-name="${esc(m.name)}" data-available="${m.availableCount}" data-sold="${m.soldCount}">${m.active?"Archive":"Reactivate"}</button>${m.totalCount===0?`<button class="delete-backup" data-delete-model="${m.id}" data-name="${esc(m.name)}">Delete</button>`:""}</div></article>`).join("")||"<p>No models configured.</p>"}</div>`;panel.querySelector(".admin-intro").insertAdjacentElement("afterend",modelSection);
const finishModelChange=async message=>{state.models=await api("/api/models");toast(message);renderAdmin();};
$("#createModelForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/admin/models",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await finishModelChange("Model added.");}catch(error){toast(error.message);}};
+1
View File
@@ -7,3 +7,4 @@
.auth-pending #appShell,.auth-required #appShell{display:none}.role-admin #authScreen,.role-readonly #authScreen{display:none}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:24px;background:linear-gradient(145deg,#eef4ff,#f8fafc)}.auth-card{width:min(430px,100%);background:#fff;border:1px solid #dfe5ef;border-radius:18px;box-shadow:0 22px 60px #17203320;padding:34px}.auth-brand{margin-bottom:28px}.auth-card h1{margin:0 0 7px}.auth-card>div>p{color:#68758b}.auth-submit{width:100%;margin-top:14px}.auth-message{background:#fff0f0;color:#a51d1d;border:1px solid #fecaca;border-radius:8px;padding:10px 12px;margin:14px 0}.auth-logout{width:100%;margin-top:10px}.user-menu{display:flex;align-items:center;gap:10px;border-left:1px solid #e3e7ef;padding-left:18px}.user-menu span{display:grid;font-size:13px}.user-menu small{color:#758197}.admin-section{display:grid;gap:16px;border-bottom:1px solid #e3e7ef;padding-bottom:28px}.admin-section:last-child{border-bottom:0}.inline-user-form{display:grid;grid-template-columns:1fr 1fr 180px auto;gap:10px;align-items:end}.inline-user-form label{margin:0}.user-list,.audit-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.user-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.user-list article:last-child{border-bottom:0}.user-list small{display:block;color:#7b8799;margin-top:4px}.user-list article>div:last-child{display:flex;gap:7px;flex-wrap:wrap}.delete-backup{background:#fff0f0;color:#c62828}.audit-list{max-height:420px;overflow:auto}.audit-list article{display:grid;grid-template-columns:140px 180px 1fr;gap:12px;padding:10px 13px;border-bottom:1px solid #edf0f5;font-size:13px}.audit-list span{text-transform:capitalize}.audit-list small{color:#7b8799}@media(max-width:900px){.inline-user-form{grid-template-columns:1fr 1fr}.user-list article{align-items:flex-start;flex-direction:column}.audit-list article{grid-template-columns:1fr}.user-menu span{display:none}}@media(max-width:600px){.inline-user-form{grid-template-columns:1fr}.user-menu{padding-left:5px}.user-menu button{font-size:12px;padding:8px}.auth-card{padding:25px}}
.inline-model-form{display:grid;grid-template-columns:minmax(220px,1fr) auto;gap:10px;align-items:end}.inline-model-form label{margin:0}.model-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.model-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.model-list article:last-child{border-bottom:0}.model-summary>div{display:flex;align-items:center;gap:9px}.model-summary small{display:block;color:#7b8799;margin-top:5px}.model-status{display:inline-flex;border-radius:99px;padding:3px 8px;font-size:11px;font-weight:750}.model-status.active{background:#dcfce7;color:#166534}.model-status.archived{background:#eef1f5;color:#526071}.model-actions{display:flex;gap:7px;flex-wrap:wrap}@media(max-width:760px){.inline-model-form{grid-template-columns:1fr}.model-list article{align-items:stretch;flex-direction:column}.model-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.model-actions button{text-align:center}}
.conditional-fields{margin:2px 0 14px;padding:16px;border:1px solid #e3e7ef;border-radius:11px;background:#f8fafc}.conditional-fields h3{margin:0 0 12px}.conditional-hint{margin:4px 0 16px;padding:13px;border:1px dashed #d7deea;border-radius:9px;color:#68758b}.field-help{display:block;margin-top:-8px;color:#68758b}.warranty-pill{display:inline-flex;align-items:center;border-radius:99px;padding:5px 9px;font-size:12px;font-weight:750;white-space:nowrap}.warranty-pill.in-warranty{background:#dcfce7;color:#166534}.warranty-pill.expired{background:#fee2e2;color:#b91c1c}.warranty-pill.neutral{background:#eef1f5;color:#526071}.inline-warranty-form{display:grid;grid-template-columns:minmax(180px,1fr) 130px 150px auto;gap:10px;align-items:end}.inline-warranty-form label{margin:0}.warranty-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.warranty-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.warranty-list article:last-child{border-bottom:0}@media(max-width:760px){.inline-warranty-form{grid-template-columns:1fr}.warranty-list article{align-items:stretch;flex-direction:column}}
.filter-bar{display:flex;align-items:end;gap:9px;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #e3e7ef}.filter-bar label{margin:0;min-width:125px;font-size:11px}.filter-bar select,.filter-bar input{padding:8px 9px;font-size:13px}.filter-bar .button,.filter-bar button{padding:9px 12px}.export-button{text-decoration:none}.customer-match{min-height:18px;margin:-7px 0 10px!important;color:#1d4ed8!important;font-size:12px}.diagnostic-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.diagnostic-grid article{display:grid;gap:4px;padding:14px;border:1px solid #e3e7ef;border-radius:10px}.diagnostic-grid span,.diagnostic-grid small{color:#68758b}.diagnostic-grid strong{font-size:18px}.backup-schedule{display:grid;grid-template-columns:minmax(220px,1fr) 180px 200px auto;gap:10px;align-items:end}.backup-schedule label{margin:0}.check-label{display:flex!important;align-items:center;gap:8px;min-height:42px}.check-label input{width:auto}.export-links{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.export-links .button{padding:8px 11px;text-decoration:none}@media(max-width:900px){.diagnostic-grid{grid-template-columns:1fr 1fr}.backup-schedule{grid-template-columns:1fr 1fr}}@media(max-width:600px){.filter-bar{align-items:stretch}.filter-bar label,.filter-bar .button,.filter-bar button{width:100%}.diagnostic-grid,.backup-schedule{grid-template-columns:1fr}}
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f5f7fb"><meta name="application-name" content="vBoxStock"><meta name="apple-mobile-web-app-title" content="vBoxStock"><title>vBoxStock</title><script>try{const t=localStorage.getItem("vboxstock-theme")||"system",d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch{}</script><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"><link rel="manifest" href="/site.webmanifest"><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"><link rel="stylesheet" href="/theme.css"></head>
<body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><div id="authBody"></div></div></section><div id="appShell"><header><div class="brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><input id="search" type="search" aria-label="Search inventory, sales, and customers" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive"> Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><span id="currentUser"></span><label class="theme-control"><span>Theme</span><select id="themeSelect" aria-label="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><button class="secondary" id="logout">Log out</button></div></header>
<main><div class="intro"><h1>Inventory Overview</h1><span id="date"></span></div><section class="stats"><article><span>Available products</span><strong id="availableCount">0</strong><small>Ready to sell</small></article><article><span>Sold this month</span><strong id="soldCount">0</strong><small id="revenue">No sales recorded</small></article><article><span>Inventory value</span><strong id="value">$0</strong><small>Based on purchase cost</small></article></section>
<section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main>
<section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div id="filterBar" class="filter-bar"></div><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main>
<dialog id="modal"><button class="close" aria-label="Close">×</button><div id="modalBody"></div></dialog><div id="toast" hidden></div></div><script type="module" src="/app.js"></script></body></html>
+1
View File
@@ -26,6 +26,7 @@ th{background:var(--surface-table);color:var(--muted-2)}th,td,.records nav,.back
.customer-notes article,.backup-list,.user-list,.model-list,.warranty-list,.audit-list{border-color:var(--border)}
.model-list article{border-color:var(--border)}.model-summary small{color:var(--muted)}.model-status.active{background:var(--green-soft);color:var(--green)}.model-status.archived{background:var(--surface-soft);color:var(--muted)}
.warranty-list article,.conditional-fields{border-color:var(--border)}.conditional-fields{background:var(--surface-soft)}.conditional-hint{border-color:var(--border-strong);color:var(--muted)}.field-help{color:var(--muted)}.warranty-pill.in-warranty{background:var(--green-soft);color:var(--green)}.warranty-pill.expired{background:var(--danger-soft);color:var(--danger)}.warranty-pill.neutral{background:var(--surface-soft);color:var(--muted)}
.filter-bar{border-color:var(--border);background:var(--surface-soft)}.filter-bar .button{background:var(--surface);border:1px solid var(--border-strong);color:var(--text)}.customer-match{color:var(--blue-text)!important}.diagnostic-grid article{background:var(--surface-soft);border-color:var(--border)}.diagnostic-grid span,.diagnostic-grid small{color:var(--muted)}.export-links .button{background:var(--surface-soft);border:1px solid var(--border-strong);color:var(--text)}
.note-category{background:var(--blue-soft);color:var(--blue-text)}.backup-warning{background:var(--warning-soft);border-color:color-mix(in srgb,var(--warning) 40%,transparent);color:var(--warning)}
.auth-screen{background:linear-gradient(145deg,color-mix(in srgb,var(--blue) 10%,var(--page)),var(--page))}.auth-card{box-shadow:0 22px 60px var(--shadow)}
dialog{max-height:min(90dvh,900px);overflow:auto}dialog::backdrop{background:#050914aa}.close{color:var(--muted)}