Files
vboxstock/public/app.js
T
2026-09-21 10:18:10 -04:00

187 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", "'":"&#39;", '"':"&quot;" })[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"});
async function scanLabelWithLibrary(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,controls;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();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 one barcode at a time</strong><small>Scan UID, serial number, and MAC on the same label.</small><button type="button">Close camera</button></div>';overlay.prepend(video);document.body.append(overlay);const finish=()=>{controls?.stop();stream.getTracks().forEach(track=>track.stop());overlay.remove()};overlay.querySelector("button").onclick=finish;await video.play();controls=await reader.decodeFromVideoElement(video,(result,error)=>{if(!result)return;const clean=String(result.getText()||"").trim().replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(!clean||[form.elements.uid.value,form.elements.sn.value,form.elements.mac.value].includes(clean))return;if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(clean)||/^[0-9a-f]{12}$/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;help.textContent=form.elements.uid.value&&form.elements.sn.value&&form.elements.mac.value?"All three identifiers scanned. Close the camera and add this device.":"Barcode captured. Aim at the next code."})}catch(error){if(controls)controls.stop();if(stream)stream.getTracks().forEach(track=>track.stop());if(overlay)overlay.remove();help.textContent="Automatic scanning could not load. Enter the three identifiers manually or try Chrome."}}
async function scanLabelPhoto(form,help,file){let url;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),found=[];canvas.width=image.naturalWidth;canvas.height=Math.floor(image.naturalHeight/3);for(let i=0;i<3;i++){ctx.clearRect(0,0,canvas.width,canvas.height);ctx.drawImage(image,0,i*canvas.height,canvas.width,canvas.height,0,0,canvas.width,canvas.height);try{const result=reader.decodeFromCanvas(canvas),value=String(result.getText()||"").trim().replace(/^\\s*(UID|SN|MAC)\\s*[:#]?\\s*/i,"");if(value&&!found.includes(value))found.push(value)}catch{}}for(const clean of found){if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(clean)||/^[0-9a-f]{12}$/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}help.textContent=found.length?found.length+" barcode"+(found.length===1?"":"s")+" read from the photo. Review the fields before adding the device.":"No barcodes were detected. Retake the photo with the full label in focus."}catch(error){help.textContent="The photo could not be processed. Retake it in brighter light or enter the values manually."}finally{if(url)URL.revokeObjectURL(url);file=null}}
const receiveObserver=new MutationObserver(()=>{const form=$("#receiveForm");if(!form||form.querySelector(".photo-scan"))return;const button=document.createElement("button");button.type="button";button.className="secondary photo-scan";button.textContent="Take photo of label";const input=document.createElement("input");input.type="file";input.accept="image/*";input.capture="environment";input.className="photo-input";input.style.position="absolute";input.style.width="1px";input.style.height="1px";input.style.opacity="0";input.style.pointerEvents="none";form.querySelector(".scan-panel .scan-camera").after(button,input);button.onclick=()=>input.click();input.onchange=()=>{if(input.files?.[0])scanLabelPhoto(form,form.querySelector(".scan-help"),input.files[0]);input.value=""}});receiveObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoFixed(form,help,file){let url;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),found=[];canvas.width=image.naturalWidth;canvas.height=Math.floor(image.naturalHeight/3);for(let i=0;i<3;i++){ctx.clearRect(0,0,canvas.width,canvas.height);ctx.drawImage(image,0,i*canvas.height,canvas.width,canvas.height,0,0,canvas.width,canvas.height);try{const result=reader.decodeFromCanvas(canvas),value=String(result.getText()||"").trim().replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(value&&!found.includes(value))found.push(value)}catch{}}if(found.length===3){form.elements.uid.value=found[0];form.elements.sn.value=found[1];form.elements.mac.value=found[2]}else for(const value of found){const clean=value.replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(clean)||/^[0-9a-f]{12}$/i.test(clean))form.elements.mac.value=clean;else if(/^V[0-9A-Z]/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}help.textContent=found.length?found.length+" barcode"+(found.length===1?"":"s")+" read from the photo. Review the fields before adding the device.":"No barcodes were detected. Retake the photo with the full label in focus."}catch(error){help.textContent="The photo could not be processed. Retake it in brighter light or enter the values manually."}finally{if(url)URL.revokeObjectURL(url)}}
const photoFixObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.fixed){input.dataset.fixed="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoFixed($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});photoFixObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoBetter(form,help,file){let url;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),found=[];canvas.width=image.naturalWidth;canvas.height=image.naturalHeight;const regions=[{y:0,h:1},{y:.2,h:.65},{y:.33,h:.22},{y:.52,h:.22},{y:.7,h:.22}];for(const region of regions){const sy=Math.floor(image.naturalHeight*region.y),sh=Math.floor(image.naturalHeight*region.h);canvas.width=image.naturalWidth;canvas.height=sh;ctx.drawImage(image,0,sy,image.naturalWidth,sh,0,0,canvas.width,canvas.height);try{const result=await reader.decodeFromImageUrl(canvas.toDataURL("image/jpeg",.95)),value=String(result.getText()||"").trim().replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(value&&!found.includes(value))found.push(value)}catch{}}if(found.length===3){form.elements.uid.value=found[0];form.elements.sn.value=found[1];form.elements.mac.value=found[2]}else for(const value of found){if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(value)||/^[0-9a-f]{12}$/i.test(value))form.elements.mac.value=value;else if(/^V[0-9A-Z]/i.test(value))form.elements.sn.value=value;else if(!form.elements.uid.value)form.elements.uid.value=value;else if(!form.elements.sn.value)form.elements.sn.value=value;else if(!form.elements.mac.value)form.elements.mac.value=value}help.textContent=found.length?found.length+" barcode"+(found.length===1?"":"s")+" read from the photo. Review the fields before adding the device.":"No barcodes were detected. Retake the photo closer and in brighter light."}catch(error){help.textContent="The photo could not be decoded. Retake it with the label filling most of the frame, or enter the values manually."}finally{if(url)URL.revokeObjectURL(url)}}
const photoBetterObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.better){input.dataset.better="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoBetter($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});photoBetterObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoFinal(form,help,file){let url;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),found=[];const regions=[{y:0,h:1},{y:.18,h:.68},{y:.32,h:.2},{y:.52,h:.2},{y:.7,h:.2}];for(const region of regions){const sy=Math.floor(image.naturalHeight*region.y),sh=Math.floor(image.naturalHeight*region.h);canvas.width=image.naturalWidth;canvas.height=sh;ctx.drawImage(image,0,sy,image.naturalWidth,sh,0,0,canvas.width,canvas.height);const crop=new Image();crop.src=canvas.toDataURL("image/jpeg",.98);await crop.decode();try{const result=await reader.decodeFromImageElement(crop),value=String(result.getText()||"").trim().replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(value&&!found.includes(value))found.push(value)}catch{}}if(found.length===3){form.elements.uid.value=found[0];form.elements.sn.value=found[1];form.elements.mac.value=found[2]}else for(const value of found){if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(value)||/^[0-9a-f]{12}$/i.test(value))form.elements.mac.value=value;else if(/^V[0-9A-Z]/i.test(value))form.elements.sn.value=value;else if(!form.elements.uid.value)form.elements.uid.value=value;else if(!form.elements.sn.value)form.elements.sn.value=value;else if(!form.elements.mac.value)form.elements.mac.value=value}help.textContent=found.length?found.length+" barcode"+(found.length===1?"":"s")+" read. Review the fields before adding the device.":"No barcodes detected. Retake the photo with the label filling most of the frame."}catch(error){help.textContent="The photo could not be decoded. Retake it closer and in brighter light, or enter the values manually."}finally{if(url)URL.revokeObjectURL(url)}}
const photoFinalObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.final){input.dataset.final="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoFinal($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});photoFinalObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoPreprocessed(form,help,file){let url;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const source=document.createElement("canvas"),sctx=source.getContext("2d");source.width=image.naturalWidth;source.height=image.naturalHeight;sctx.drawImage(image,0,0);const found=[],regions=[{y:0,h:1},{y:.15,h:.7},{y:.28,h:.24},{y:.48,h:.24},{y:.68,h:.24}];for(const region of regions){const sy=Math.floor(source.height*region.y),sh=Math.floor(source.height*region.h),canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),scale=2;canvas.width=source.width*scale;canvas.height=sh*scale;ctx.drawImage(source,0,sy,source.width,sh,0,0,canvas.width,canvas.height);const pixels=ctx.getImageData(0,0,canvas.width,canvas.height);for(let i=0;i<pixels.data.length;i+=4){const gray=(pixels.data[i]*.299+pixels.data[i+1]*.587+pixels.data[i+2]*.114);const value=gray>175?255:gray<80?0:gray;pixels.data[i]=pixels.data[i+1]=pixels.data[i+2]=value}ctx.putImageData(pixels,0,0);const crop=new Image();crop.src=canvas.toDataURL("image/png");await crop.decode();try{const result=await reader.decodeFromImageElement(crop),value=String(result.getText()||"").trim().replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(value&&!found.includes(value))found.push(value)}catch{}}if(found.length===3){form.elements.uid.value=found[0];form.elements.sn.value=found[1];form.elements.mac.value=found[2]}else for(const value of found){if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(value)||/^[0-9a-f]{12}$/i.test(value))form.elements.mac.value=value;else if(/^V[0-9A-Z]/i.test(value))form.elements.sn.value=value;else if(!form.elements.uid.value)form.elements.uid.value=value;else if(!form.elements.sn.value)form.elements.sn.value=value;else if(!form.elements.mac.value)form.elements.mac.value=value}help.textContent=found.length?found.length+" barcode"+(found.length===1?"":"s")+" read after image enhancement. Review the fields before adding the device.":"No barcodes detected after image enhancement. Try a closer, brighter photo."}catch(error){help.textContent="The enhanced photo could not be decoded. Enter the identifiers manually."}finally{if(url)URL.revokeObjectURL(url)}}
const photoPreprocessObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.preprocessed){input.dataset.preprocessed="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoPreprocessed($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});photoPreprocessObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoOcr(form,help,file){let url,worker;try{const Tesseract=await import("https://cdn.jsdelivr.net/npm/tesseract.js@5/+esm");url=URL.createObjectURL(file);worker=await Tesseract.createWorker("eng");const result=await worker.recognize(url),text=String(result.data.text||"").replace(/\r/g," ");const uid=text.match(/UID\s*[:#]?\s*([A-Z0-9]+)/i)?.[1],sn=text.match(/SN\s*[:#]?\s*([A-Z0-9]+)/i)?.[1],mac=text.match(/MAC\s*[:#]?\s*((?:[A-F0-9]{2}[:\-]?){6})/i)?.[1];if(uid)form.elements.uid.value=uid;if(sn)form.elements.sn.value=sn;if(mac)form.elements.mac.value=mac.replace(/-/g,":").match(/.{2}/g)?.join(":")||mac;const count=[uid,sn,mac].filter(Boolean).length;help.textContent=count?count+" labeled value"+(count===1?"":"s")+" read from the photo. Review the fields before adding the device.":"No UID, SN, or MAC text was recognized. Retake the photo with the label filling most of the frame."}catch(error){help.textContent="The printed label text could not be recognized. Retake it closer and in brighter light, or enter the values manually."}finally{await worker?.terminate().catch(()=>{});if(url)URL.revokeObjectURL(url)}}
const photoOcrObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.ocr){input.dataset.ocr="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoOcr($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});photoOcrObserver.observe($("#modalBody"),{childList:true});
if(!("BarcodeDetector" in window))document.addEventListener("click",e=>{const button=e.target.closest(".scan-camera");if(button){e.stopPropagation();scanLabelWithLibrary(button.closest("form"),button.closest("form").querySelector(".scan-help"))}},true);
applyTheme();
initialize();