174 lines
60 KiB
JavaScript
174 lines
60 KiB
JavaScript
const $ = (s) => document.querySelector(s);
|
||
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);
|
||
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.includes("T")?v:`${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 => ({ "&":"&", "<":"<", ">":">", "'":"'", '"':""" })[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";
|
||
function storageStatus(state,message) { const el=$("#storageStatus"); if(!el)return; el.className=`storage-status ${state}`; el.querySelector("b").textContent=message; }
|
||
const systemTheme=window.matchMedia("(prefers-color-scheme: dark)");
|
||
function savedTheme(){try{return localStorage.getItem("vboxstock-theme")||"system"}catch{return "system"}}
|
||
function applyTheme(choice=savedTheme()){const resolved=choice==="system"?(systemTheme.matches?"dark":"light"):choice;document.documentElement.dataset.theme=resolved;document.querySelector('meta[name="theme-color"]').content=resolved==="dark"?"#0d1320":"#f5f7fb";if($("#themeSelect"))$("#themeSelect").value=choice;}
|
||
function decorateResponsiveTable(){const labels=[...document.querySelectorAll("#thead th")].map(th=>th.textContent.trim()||"Actions");document.querySelectorAll("#rows tr").forEach(row=>[...row.children].forEach((cell,index)=>cell.dataset.label=labels[index]||"Details"));}
|
||
|
||
async function api(path, options = {}) {
|
||
const writing=options.method&&options.method!=="GET"; if(writing)storageStatus("saving","Saving to database");
|
||
try { const response = await fetch(path, { headers: { "content-type":"application/json" }, ...options });
|
||
if (response.status === 204) { storageStatus("connected","Database connected"); return null; }
|
||
const data = await response.json();
|
||
if (!response.ok) { storageStatus("connected","Database connected"); const error=new Error(data.error||"Something went wrong."); error.databaseHealthy=true; error.status=response.status; error.code=data.code; if(response.status===401&&state.user)showLogin("Your session expired. Please sign in again."); throw error; }
|
||
storageStatus("connected","Database connected"); return data;
|
||
} catch(error) { if(!error.databaseHealthy)storageStatus("critical","Database error"); throw error; }
|
||
}
|
||
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 warrantyState(p){if(!p.warrantyName)return{label:"Not recorded",className:"neutral"};if(!p.warrantyEndDate)return{label:p.warrantyName==="No Warranty"?"No Warranty":p.warrantyName,className:"neutral"};const days=Math.round((new Date(`${p.warrantyEndDate}T12:00:00Z`)-new Date(`${today()}T12:00:00Z`))/86400000);return days>=0?{label:`In Warranty · ${days} day${days===1?"":"s"} remaining`,className:"in-warranty"}:{label:`Expired · ${Math.abs(days)} day${days===-1?"":"s"} ago`,className:"expired"};}
|
||
function warrantyBadge(p){const w=warrantyState(p);return `<span class="warranty-pill ${w.className}">${esc(w.label)}</span>`;}
|
||
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||"")}" 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();
|
||
state.products.filter(p=>p.status==="sold"&&p.customerName).forEach(p=>{
|
||
const key=p.customerId||p.customerName.toLowerCase(), current=grouped.get(key)||{key,name:p.customerName,phone:p.phone,customerId:p.customerId,purchases:[],representative:p};
|
||
current.purchases.push(p); if((p.soldAt||"")>(current.representative.soldAt||"")) current.representative=p; grouped.set(key,current);
|
||
});
|
||
return [...grouped.values()].map(c=>({...c,count:c.purchases.length,total:c.purchases.reduce((n,p)=>n+Number(p.salePrice||0),0),lastPurchase:c.representative.soldAt})).sort((a,b)=>(b.lastPurchase||"").localeCompare(a.lastPurchase||""));
|
||
}
|
||
|
||
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)))&&(!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;
|
||
$("#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; $("#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);
|
||
const start=(state.page-1)*PAGE_SIZE, rows=all.slice(start,start+PAGE_SIZE);
|
||
$("#thead").innerHTML=state.tab==="available"?"<tr><th>Product</th><th>UID / SN / MAC</th><th>Received</th><th>Cost</th><th>Status</th><th></th></tr>":state.tab==="customers"?"<tr><th>Customer</th><th>Phone</th><th>Purchases</th><th>Last purchase</th><th>Total spent</th><th></th></tr>":"<tr><th>Product</th><th>Customer</th><th>Sold</th><th>Payment</th><th>Sale price</th><th>Warranty</th><th></th></tr>";
|
||
$("#rows").innerHTML=rows.map(p=>state.tab==="available"?inventoryRow(p):state.tab==="customers"?customerRow(p):saleRow(p)).join("");
|
||
decorateResponsiveTable();
|
||
$("#empty").hidden=Boolean(rows.length); $("#empty").textContent=state.query?"No records match your search.":state.tab==="available"?"No products are available.":state.tab==="customers"?"Customer records will appear after the first sale.":"No sales have been recorded.";
|
||
$("#pagination").innerHTML=all.length?`<span>Showing ${start+1}–${Math.min(start+PAGE_SIZE,all.length)} of ${all.length}</span><div><button data-page="prev" ${state.page===1?"disabled":""}>Previous</button><strong>Page ${state.page} of ${pages}</strong><button data-page="next" ${state.page===pages?"disabled":""}>Next</button></div>`:"";
|
||
}
|
||
function inventoryRow(p) { return `<tr><td class="product"><strong>${esc(p.model)}</strong><small>${esc(p.manufacturer)} · ${esc(p.condition)}</small></td><td><div class="ids">${idCell(p)}</div></td><td>${fmtDate(p.receivedAt)}</td><td>${p.cost?money.format(p.cost):"—"}</td><td><span class="pill">Available</span></td><td><div class="row-actions">${state.user.role==="admin"?`<button data-sell="${p.id}">Sell</button><button class="danger" data-delete="${p.id}">Delete</button>`:""}</div></td></tr>`; }
|
||
function saleRow(p) { return `<tr><td class="product"><strong>${esc(p.model)}</strong><small>${esc(p.manufacturer)} · ${esc(primaryId(p))}</small></td><td class="customer"><button class="customer-link" data-customer="${p.customerId||""}" data-id="${p.id}">${esc(p.customerName||"Unknown")}</button>${p.phone?`<small>${esc(p.phone)}</small>`:""}</td><td>${fmtDate(p.soldAt)}</td><td>${esc(p.paymentMethod||"—")}${p.paymentReference?`<small>${esc(p.paymentReference)}</small>`:""}</td><td>${p.salePrice?money.format(p.salePrice):"—"}</td><td>${warrantyBadge(p)}</td><td><div class="row-actions"><button data-view="${p.id}">View</button>${state.user.role==="admin"?`<button data-restock="${p.id}">Void & restock</button><button class="danger" data-delete="${p.id}">Delete</button>`:""}</div></td></tr>`; }
|
||
function customerRow(c) { return `<tr><td class="customer"><strong>${esc(c.name)}</strong></td><td>${esc(c.phone||"—")}</td><td>${c.count}</td><td>${fmtDate(c.lastPurchase)}</td><td>${c.total?money.format(c.total):"—"}</td><td><button class="customer-link" data-customer="${c.customerId||""}" data-id="${c.representative.id}">View customer</button></td></tr>`; }
|
||
|
||
function receiveForm() {
|
||
if(!state.models.length){openModal('<h2>No active models</h2><p>Add or reactivate a product model from the Admin page before receiving inventory.</p><div class="form-actions"><button class="primary" data-cancel>Close</button></div>');$("[data-cancel]").onclick=closeModal;return;}
|
||
openModal(`<h2>Receive inventory</h2><p>Enter the shared batch details, then scan each device label. The three barcodes are added as one device at a time.</p><form id="receiveForm"><div class="form-row"><label>Quantity<input name="quantity" type="number" min="1" max="100" value="1" required></label><label>Model<select name="model" required>${state.models.map(m=>`<option value="${esc(m.name)}">${esc(m.name)}</option>`).join("")}</select></label></div><div class="form-row"><label>Condition<select name="condition"><option>New</option><option>Used</option><option>Refurbished</option></select></label><label>Received date<input name="receivedAt" type="date" value="${today()}" required></label></div><div class="form-row"><label>Purchase cost per device<input name="cost" type="number" min="0" step=".01"></label><label>Notes<textarea name="notes" rows="1"></textarea></label></div><section class="scan-panel"><div class="scan-panel-heading"><h3>Current device</h3><span data-scan-progress>0 of 1 added</span></div><div class="scan"><label>UID<input name="uid" autocomplete="off" autofocus></label><label>Serial number<input name="sn" autocomplete="off"></label><label>MAC address<input name="mac" autocomplete="off"></label></div><button type="button" class="secondary scan-camera">Scan label with camera</button><button type="button" class="secondary add-device">Add device to batch</button><p class="field-help scan-help">Scan the three barcodes on one label, or enter them manually. Duplicate identifiers are rejected.</p></section><div class="batch-list" data-batch-list><p class="empty-batch">No devices added yet.</p></div><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Save batch</button></div></form>`);
|
||
const form=$("#receiveForm"),batch=[],quantity=form.elements.quantity,progress=form.querySelector("[data-scan-progress]"),list=form.querySelector("[data-batch-list]"),help=form.querySelector(".scan-help");
|
||
const duplicate=(row)=>[row.uid,row.sn,row.mac].filter(Boolean).some(value=>{const key=value.trim().toLowerCase();return batch.some(x=>[x.uid,x.sn,x.mac].some(y=>y.trim().toLowerCase()===key))||state.products.some(x=>[x.uid,x.sn,x.mac].some(y=>y&&y.trim().toLowerCase()===key))});
|
||
const renderBatch=()=>{progress.textContent=`${batch.length} of ${quantity.value||1} added`;list.innerHTML=batch.length?batch.map((x,i)=>`<div class="batch-row"><strong>#${i+1}</strong><span>UID ${esc(x.uid||"—")}</span><span>SN ${esc(x.sn||"—")}</span><span>MAC ${esc(x.mac||"—")}</span><button type="button" class="danger" data-remove-batch="${i}">Remove</button></div>`).join(""):'<p class="empty-batch">No devices added yet.</p>';form.querySelector("button.primary").disabled=batch.length!==Number(quantity.value||1)};
|
||
form.elements.quantity.oninput=()=>{if(Number(quantity.value)>100)quantity.value=100;renderBatch()};form.querySelector(".add-device").onclick=()=>{const row={uid:form.elements.uid.value.trim(),sn:form.elements.sn.value.trim(),mac:form.elements.mac.value.trim()};if(!row.uid&&!row.sn&&!row.mac){help.textContent="Enter or scan at least one identifier.";return}if(duplicate(row)){help.textContent="Duplicate identifier detected. Scan a different device.";return}if(batch.length>=Number(quantity.value)){help.textContent="The requested quantity is already filled.";return}batch.push(row);form.elements.uid.value=form.elements.sn.value=form.elements.mac.value="";help.textContent="Device added. Scan the next label.";renderBatch();form.elements.uid.focus()};list.onclick=e=>{const button=e.target.closest("[data-remove-batch]");if(button){batch.splice(Number(button.dataset.removeBatch),1);renderBatch()}};
|
||
form.querySelector(".scan-camera").onclick=()=>scanLabel(form,help);renderBatch();form.onsubmit=async e=>{e.preventDefault();if(batch.length!==Number(quantity.value)){help.textContent=`Add all ${quantity.value} devices before saving.`;return}const data=Object.fromEntries(new FormData(form));delete data.quantity;delete data.uid;delete data.sn;delete data.mac;data.items=batch;await change("/api/products/batch","POST",data,`${batch.length} ${batch.length===1?"product":"products"} received.`)};$("[data-cancel]").onclick=closeModal;
|
||
}
|
||
async function scanLabel(form,help){if(!("BarcodeDetector" in window)){help.textContent="Camera barcode scanning is not supported by this browser. Enter the values manually or use a supported mobile browser.";return}if(!navigator.mediaDevices?.getUserMedia){help.textContent="Camera access is unavailable. Use manual entry or enable camera access for this site.";return}let stream;try{stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:"environment"}}});const detector=new BarcodeDetector({formats:["code_128","code_39","ean_13","ean_8","itf"]}),video=document.createElement("video");video.autoplay=true;video.playsInline=true;video.srcObject=stream;const overlay=document.createElement("div");overlay.className="scanner-overlay";overlay.innerHTML='<div><strong>Point at the full label</strong><small>Hold steady while the three barcodes are read.</small><button type="button">Cancel</button></div>';overlay.prepend(video);document.body.append(overlay);let cancelled=false;const finish=()=>{cancelled=true;stream.getTracks().forEach(track=>track.stop());overlay.remove()};overlay.querySelector("button").onclick=finish;await video.play();let started=Date.now();while(Date.now()-started<15000&&!cancelled){const codes=await detector.detect(video),values=codes.map(x=>String(x.rawValue||"").trim()).filter(Boolean);for(const value of values){const clean=value.replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(clean))form.elements.mac.value=clean;else if(/^V\w/i.test(clean))form.elements.sn.value=clean;else if(!form.elements.uid.value)form.elements.uid.value=clean;else if(!form.elements.sn.value)form.elements.sn.value=clean;else if(!form.elements.mac.value)form.elements.mac.value=clean}if(form.elements.uid.value&&form.elements.sn.value&&form.elements.mac.value){help.textContent="All three identifiers scanned. Add this device to the batch.";break}await new Promise(resolve=>setTimeout(resolve,120))}finish()}catch(error){if(stream)stream.getTracks().forEach(track=>track.stop());help.textContent="Unable to read that label. Try better lighting or enter the identifiers manually."}}
|
||
function sellForm(id="") {
|
||
const available=state.products.filter(p=>p.status==="available");
|
||
if(!available.length){openModal('<h2>No available inventory</h2><p>Receive a product before recording a sale.</p><div class="form-actions"><button class="primary" data-cancel>Close</button></div>');$("[data-cancel]").onclick=closeModal;return;}
|
||
openModal(`<h2>Record a sale</h2><p>Select one or more products. Customer, payment, fulfillment, warranty, and notes apply to every selected item.</p><form id="sellForm"><fieldset class="sale-items"><legend>Products</legend><div class="sale-item-list">${available.map(p=>`<label class="sale-item"><input type="checkbox" name="productIds" value="${p.id}" ${p.id===id?"checked":""}><span><strong>${esc(p.model)}</strong><small>${esc(primaryId(p))}</small></span><span class="sale-item-price">Sale price<input type="number" min="0" step=".01" inputmode="decimal" data-price="${p.id}" aria-label="Sale price for ${esc(p.model)} ${esc(primaryId(p))}" ${p.id===id?"":"disabled"}></span></label>`).join("")}</div><div class="sale-summary" aria-live="polite"><span><strong data-selected-count>0</strong> selected</span><strong data-sale-total>${money.format(0)}</strong></div></fieldset>${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><label>Date sold<input name="soldAt" type="date" value="${today()}" required></label><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"));
|
||
const form=$("#sellForm"),checks=[...form.querySelectorAll('[name="productIds"]')],drawTotal=()=>{let count=0,total=0;checks.forEach(check=>{const price=form.querySelector(`[data-price="${check.value}"]`);price.disabled=!check.checked;if(check.checked){count++;total+=Number(price.value)||0}});form.querySelector("[data-selected-count]").textContent=count;form.querySelector("[data-sale-total]").textContent=money.format(total);form.querySelector('.form-actions .primary').textContent=count>1?`Complete sale (${count} items)`:"Complete sale";};checks.forEach(check=>check.onchange=drawTotal);form.querySelectorAll("[data-price]").forEach(input=>input.oninput=drawTotal);drawTotal();
|
||
bindCustomer(form);form.onsubmit=async e=>{e.preventDefault();const button=e.submitter,selected=checks.filter(x=>x.checked);if(!selected.length){toast("Select at least one product.");return;}button.disabled=true;const data=Object.fromEntries(new FormData(form));delete data.productIds;data.items=selected.map(check=>({productId:check.value,salePrice:Number(form.querySelector(`[data-price="${check.value}"]`).value)||0}));try{await change("/api/sales","POST",data,`${selected.length} ${selected.length===1?"item":"items"} sold.`);}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); }
|
||
// Re-declared here to keep camera access available on Safari, where BarcodeDetector may be absent.
|
||
async function scanLabelCompat(form,help){if(!navigator.mediaDevices?.getUserMedia){help.textContent="Camera access is unavailable. Use manual entry or enable camera access for this site.";return}let stream,overlay;try{stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:"environment"}}});const video=document.createElement("video");video.autoplay=true;video.playsInline=true;video.srcObject=stream;overlay=document.createElement("div");overlay.className="scanner-overlay";overlay.innerHTML='<div><strong>Point at the full label</strong><small>This browser can show the camera but cannot decode barcodes automatically. Enter the three values below, then close this view.</small><button type="button">Close camera</button></div>';overlay.prepend(video);document.body.append(overlay);const finish=()=>{stream.getTracks().forEach(track=>track.stop());overlay.remove()};overlay.querySelector("button").onclick=finish;await video.play();help.textContent="Camera is open. Enter the identifiers below, then close this view."}catch(error){if(stream)stream.getTracks().forEach(track=>track.stop());if(overlay)overlay.remove();help.textContent="Unable to open the camera. Check browser permissions or enter the identifiers manually."}}
|
||
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) {
|
||
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>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(" · ");
|
||
const noteForm=state.user.role==="admin"?`<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>`:"";
|
||
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 permanent address recorded"}</p>${c.shippingNotes?`<p><small>${esc(c.shippingNotes)}</small></p>`:""}</section><section class="customer-notes"><h3>Customer notes</h3>${noteForm}${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>${state.user.role==="admin"?`<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><p>${warrantyBadge(s)}</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)));
|
||
if($("#customerNoteForm"))$("#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); }
|
||
}
|
||
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,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,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 diagnostics</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><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);
|
||
const backupSection=[...panel.querySelectorAll(".admin-section")].find(section=>section.querySelector("h2")?.textContent==="Database backups"),schedule=document.createElement("div"),weekdays=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];schedule.className="automatic-backups";schedule.innerHTML=`<div><h3>Automatic backups</h3><p>Run a retained local backup every day or once per week. Manual, pre-upgrade, and pre-restore backups are never removed automatically.</p></div><form id="backupScheduleForm" class="backup-schedule"><label class="check-label"><input name="enabled" type="checkbox" ${diagnostics.backupSettings.enabled?"checked":""}> Enable automatic backups</label><label>Frequency<select name="frequency"><option value="daily" ${diagnostics.backupSettings.frequency==="daily"?"selected":""}>Every day</option><option value="weekly" ${diagnostics.backupSettings.frequency==="weekly"?"selected":""}>Weekly</option></select></label><label class="weekly-field">Day of week<select name="weekday">${weekdays.map((day,index)=>`<option value="${index}" ${diagnostics.backupSettings.weekday===index?"selected":""}>${day}</option>`).join("")}</select></label><label>Backup time<select name="hour">${Array.from({length:24},(_,hour)=>`<option value="${hour}" ${diagnostics.backupSettings.hour===hour?"selected":""}>${String(hour).padStart(2,"0")}:00</option>`).join("")}</select></label><label>Automatic backups to retain<input name="retention" type="number" min="1" max="365" value="${diagnostics.backupSettings.retention}" required></label><button class="primary">Save schedule</button></form><div class="backup-schedule-status"><span><strong>Last automatic backup:</strong> ${diagnostics.backupSettings.lastScheduledBackup?fmtDate(diagnostics.backupSettings.lastScheduledBackup):"Not yet run"}</span><span><strong>Next automatic backup:</strong> ${diagnostics.backupSettings.nextScheduledBackup?fmtDateTime(diagnostics.backupSettings.nextScheduledBackup):"Disabled"}</span></div>`;backupSection.querySelector(".backup-warning").insertAdjacentElement("beforebegin",schedule);const frequency=$("#backupScheduleForm [name=frequency]"),weekly=$("#backupScheduleForm .weekly-field"),updateFrequency=()=>weekly.hidden=frequency.value!=="weekly";frequency.onchange=updateFrequency;updateFrequency();$("#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),frequency:data.frequency,weekday:Number(data.weekday),hour:Number(data.hour),retention:Number(data.retention)})});toast("Automatic 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);}};
|
||
document.querySelectorAll("[data-rename-model]").forEach(b=>b.onclick=async()=>{const name=prompt(`Rename ${b.dataset.name}:`,b.dataset.name);if(name===null||name.trim()===b.dataset.name)return;try{await api(`/api/admin/models/${encodeURIComponent(b.dataset.renameModel)}`,{method:"PATCH",body:JSON.stringify({name})});await finishModelChange("Model renamed.");}catch(error){toast(error.message);}});
|
||
document.querySelectorAll("[data-toggle-model]").forEach(b=>b.onclick=async()=>{const active=b.dataset.active==="true";if(active&&!confirm(`Archive ${b.dataset.name}? It will no longer appear for newly received products. Existing records remain available (${b.dataset.available} available, ${b.dataset.sold} sold).`))return;try{await api(`/api/admin/models/${encodeURIComponent(b.dataset.toggleModel)}`,{method:"PATCH",body:JSON.stringify({active:!active})});await finishModelChange(active?"Model archived.":"Model reactivated.");}catch(error){toast(error.message);}});
|
||
document.querySelectorAll("[data-delete-model]").forEach(b=>b.onclick=async()=>{if(!confirm(`Permanently delete unused model ${b.dataset.name}?`))return;try{await api(`/api/admin/models/${encodeURIComponent(b.dataset.deleteModel)}`,{method:"DELETE"});await finishModelChange("Unused model deleted.");}catch(error){toast(error.message);}});
|
||
const warranties=await api("/api/admin/warranties"),warrantySection=document.createElement("section");warrantySection.className="admin-section warranty-section";warrantySection.innerHTML=`<div><h2>Warranty periods</h2><p>Choose the default offered on new sales. Used periods are preserved as sale snapshots and can be archived, but not changed or deleted.</p></div><form id="createWarrantyForm" class="inline-warranty-form"><label>Name<input name="name" required maxlength="60" placeholder="Example: 6 Months"></label><label>Duration<input name="durationValue" type="number" min="0" max="3650" required></label><label>Unit<select name="durationUnit"><option value="days">Days</option><option value="months">Months</option><option value="years">Years</option></select></label><button class="primary">Add warranty</button></form><div class="warranty-list">${warranties.map(w=>`<article><div class="model-summary"><div><strong>${esc(w.name)}</strong>${w.isDefault?'<span class="model-status active">Default</span>':`<span class="model-status ${w.active?"active":"archived"}">${w.active?"Active":"Archived"}</span>`}</div><small>${w.durationValue} ${esc(w.durationUnit)} · ${w.usageCount} sale${w.usageCount===1?"":"s"}</small></div><div class="model-actions">${!w.isDefault&&w.active?`<button class="secondary" data-default-warranty="${w.id}" data-name="${esc(w.name)}">Make default</button>`:""}${w.usageCount===0?`<button class="secondary" data-edit-warranty="${w.id}" data-name="${esc(w.name)}" data-value="${w.durationValue}" data-unit="${w.durationUnit}">Edit</button>`:""}${!w.isDefault?`<button class="secondary" data-toggle-warranty="${w.id}" data-active="${w.active}" data-name="${esc(w.name)}">${w.active?"Archive":"Reactivate"}</button>`:""}${w.usageCount===0&&!w.isDefault?`<button class="delete-backup" data-delete-warranty="${w.id}" data-name="${esc(w.name)}">Delete</button>`:""}</div></article>`).join("")}</div>`;modelSection.insertAdjacentElement("afterend",warrantySection);
|
||
const finishWarrantyChange=async message=>{state.warranties=await api("/api/warranties");toast(message);renderAdmin();};
|
||
$("#createWarrantyForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/admin/warranties",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await finishWarrantyChange("Warranty period added.");}catch(error){toast(error.message);}};
|
||
document.querySelectorAll("[data-default-warranty]").forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/warranties/${encodeURIComponent(b.dataset.defaultWarranty)}`,{method:"PATCH",body:JSON.stringify({isDefault:true})});await finishWarrantyChange(`${b.dataset.name} is now the default warranty.`);}catch(error){toast(error.message);}});
|
||
document.querySelectorAll("[data-edit-warranty]").forEach(b=>b.onclick=async()=>{const name=prompt("Warranty name:",b.dataset.name);if(name===null)return;const value=prompt("Duration value:",b.dataset.value);if(value===null)return;const unit=prompt("Duration unit (days, months, or years):",b.dataset.unit);if(unit===null)return;try{await api(`/api/admin/warranties/${encodeURIComponent(b.dataset.editWarranty)}`,{method:"PATCH",body:JSON.stringify({name,durationValue:Number(value),durationUnit:unit.toLowerCase()})});await finishWarrantyChange("Warranty period updated.");}catch(error){toast(error.message);}});
|
||
document.querySelectorAll("[data-toggle-warranty]").forEach(b=>b.onclick=async()=>{const active=b.dataset.active==="true";if(active&&!confirm(`Archive ${b.dataset.name}? Existing sales will retain it.`))return;try{await api(`/api/admin/warranties/${encodeURIComponent(b.dataset.toggleWarranty)}`,{method:"PATCH",body:JSON.stringify({active:!active})});await finishWarrantyChange(active?"Warranty period archived.":"Warranty period reactivated.");}catch(error){toast(error.message);}});
|
||
document.querySelectorAll("[data-delete-warranty]").forEach(b=>b.onclick=async()=>{if(!confirm(`Permanently delete unused warranty ${b.dataset.name}?`))return;try{await api(`/api/admin/warranties/${encodeURIComponent(b.dataset.deleteWarranty)}`,{method:"DELETE"});await finishWarrantyChange("Unused warranty period deleted.");}catch(error){toast(error.message);}});
|
||
$("#createUserForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/admin/users",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});toast("User created.");renderAdmin();}catch(error){toast(error.message);}};
|
||
document.querySelectorAll("[data-role-user]").forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/users/${b.dataset.roleUser}`,{method:"PATCH",body:JSON.stringify({role:b.dataset.role==="admin"?"readonly":"admin"})});toast("User role updated.");renderAdmin();}catch(e){toast(e.message);}});
|
||
document.querySelectorAll("[data-toggle-user]").forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/users/${b.dataset.toggleUser}`,{method:"PATCH",body:JSON.stringify({enabled:b.dataset.enabled!=="true"})});toast("User status updated.");renderAdmin();}catch(e){toast(e.message);}});
|
||
document.querySelectorAll("[data-reset-user]").forEach(b=>b.onclick=async()=>{const password=prompt(`Enter a temporary password for ${b.dataset.name} (8 characters minimum):`);if(password===null)return;try{await api(`/api/admin/users/${b.dataset.resetUser}/reset-password`,{method:"POST",body:JSON.stringify({password})});toast("Password reset. User must change it at next login.");renderAdmin();}catch(e){toast(e.message);}});
|
||
document.querySelectorAll("[data-delete-user]").forEach(b=>b.onclick=async()=>{if(!confirm(`Delete user ${b.dataset.name}? Disabling is preferred when historical attribution matters.`))return;try{await api(`/api/admin/users/${b.dataset.deleteUser}`,{method:"DELETE"});toast("User deleted.");renderAdmin();}catch(e){toast(e.message);}});
|
||
$("#createBackup").onclick=async()=>{try{await api("/api/admin/backups",{method:"POST",body:"{}"});toast("Database backup created.");renderAdmin();}catch(e){toast(e.message);}};
|
||
$("#restoreUpload").onchange=async e=>{const file=e.target.files[0];if(!file||!confirm(`Restore ${file.name}? Current data and user accounts will be replaced.`))return;const password=prompt("Enter your current password to confirm the restore:");if(password!==null)await restoreUpload(file,password);};
|
||
document.querySelectorAll("[data-restore-backup]").forEach(b=>b.onclick=async()=>{if(!confirm(`Restore ${b.dataset.restoreBackup}? Current data and user accounts will be replaced.`))return;const password=prompt("Enter your current password to confirm the restore:");if(password===null)return;try{storageStatus("saving","Restoring database");await api(`/api/admin/backups/${encodeURIComponent(b.dataset.restoreBackup)}/restore`,{method:"POST",body:JSON.stringify({password})});panel.innerHTML='<div class="restart-message"><h2>Restore complete</h2><p>The container is restarting. Everyone has been signed out.</p></div>';}catch(e){toast(e.message);}});
|
||
document.querySelectorAll("[data-delete-backup]").forEach(b=>b.onclick=async()=>{if(!confirm(`Permanently delete backup ${b.dataset.deleteBackup}?`))return;try{await api(`/api/admin/backups/${encodeURIComponent(b.dataset.deleteBackup)}`,{method:"DELETE"});toast("Backup deleted.");renderAdmin();}catch(e){toast(e.message);}});
|
||
}catch(e){panel.innerHTML=`<div class="restart-message"><h2>Unable to load administration</h2><p>${esc(e.message)}</p></div>`;}
|
||
}
|
||
async function restoreUpload(file,password){const panel=$("#adminPanel");try{storageStatus("saving","Restoring database");const response=await fetch("/api/admin/restore-upload",{method:"POST",headers:{"content-type":"application/octet-stream","x-confirm-password":password},body:file});const data=await response.json();if(!response.ok)throw new Error(data.error||"Restore failed");panel.innerHTML='<div class="restart-message"><h2>Restore complete</h2><p>The container is restarting. Everyone has been signed out.</p></div>';}catch(e){storageStatus("critical","Database error");toast(e.message);}}
|
||
function showLogin(message=""){
|
||
state.user=null;document.body.className="auth-required";$("#authBody").innerHTML=`<h1>Sign in</h1><p>Use your vBoxStock account to continue.</p>${message?`<div class="auth-message">${esc(message)}</div>`:""}<form id="loginForm"><label>Username<input name="username" autocomplete="username" autofocus required></label><label>Password<input name="password" type="password" autocomplete="current-password" required></label><button class="primary auth-submit">Sign in</button></form>`;
|
||
$("#loginForm").onsubmit=async e=>{e.preventDefault();const button=e.currentTarget.querySelector("button");button.disabled=true;try{const user=await api("/api/auth/login",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});user.mustChangePassword?showPasswordChange(user):await enterApp(user);}catch(error){showLogin(error.message);}};
|
||
}
|
||
function showPasswordChange(user){
|
||
state.user=user;document.body.className="auth-required";$("#authBody").innerHTML=`<h1>Change your password</h1><p>Your temporary password must be replaced before you can use vBoxStock.</p><form id="passwordForm"><label>Current password<input name="currentPassword" type="password" autocomplete="current-password" required></label><label>New password<input name="newPassword" type="password" minlength="8" autocomplete="new-password" required></label><label>Confirm new password<input name="confirmPassword" type="password" minlength="8" autocomplete="new-password" required></label><small>Passwords must contain at least 8 characters.</small><button class="primary auth-submit">Save password</button></form><button class="secondary auth-logout" id="changeLogout">Log out</button>`;
|
||
$("#passwordForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/auth/change-password",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await enterApp({...user,mustChangePassword:false});}catch(error){toast(error.message);}};$("#changeLogout").onclick=logout;
|
||
}
|
||
async function enterApp(user){state.user=user;document.body.className=user.role==="admin"?"role-admin":"role-readonly";const account=$("#currentUser"),menu=$(".user-menu");account.innerHTML=`<small>Logged in as</small><strong>${esc(user.username)}</strong><small>${user.role==="admin"?"Admin":"Read-Only"}</small>`;account.style.width="104px";account.style.textAlign="right";menu.style.marginLeft="auto";menu.style.minWidth="280px";menu.style.justifyContent="flex-end";const adminTab=document.querySelector('[data-tab="admin"]');adminTab.hidden=user.role!=="admin";adminTab.style.display=user.role==="admin"?"":"none";document.querySelectorAll(".edit-only").forEach(el=>{el.hidden=user.role!=="admin";el.style.display=user.role==="admin"?"":"none";});if(user.role!=="admin"&&state.tab==="admin")state.tab="available";await load();}
|
||
async function logout(){try{await api("/api/auth/logout",{method:"POST",body:"{}"});}catch{}showLogin();}
|
||
async function initialize(){try{const response=await fetch("/api/auth/me"),data=await response.json();if(!response.ok)return showLogin();data.mustChangePassword?showPasswordChange(data):await enterApp(data);}catch{showLogin("Unable to connect to vBoxStock.");}}
|
||
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();};
|
||
$("#logout").onclick=logout;
|
||
$("#themeSelect").onchange=e=>{try{localStorage.setItem("vboxstock-theme",e.target.value)}catch{}applyTheme(e.target.value);};
|
||
systemTheme.addEventListener?.("change",()=>{if(savedTheme()==="system")applyTheme("system")});
|
||
$("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"});
|
||
if(!("BarcodeDetector" in window))document.addEventListener("click",e=>{const button=e.target.closest(".scan-camera");if(button){e.stopPropagation();scanLabelCompat(button.closest("form"),button.closest("form").querySelector(".scan-help"))}},true);
|
||
applyTheme();
|
||
initialize();
|