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 `${esc(w.label)}`;} 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=>``).join("");} function fulfillmentFields(method="",p={}){if(!method)return'

Select a delivery method to enter its details.

';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 `

${fulfillmentTitle(method)}

${method!=="Shipped"?``:""}
${method==="Shipped"?`
`:""}${casual?'Enter at least a venue, address, or detail.':""}
`;} 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 `

`} 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])=>`${k}: ${esc(v)}`).join("") : "Not entered"; } 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='Export customers CSV';return}bar.hidden=false;const models=[...new Set(state.products.map(p=>p.model))].sort();bar.innerHTML=`${state.tab==="sold"?``:""}Export CSV`;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"?"ProductUID / SN / MACReceivedCostStatus":state.tab==="customers"?"CustomerPhonePurchasesLast purchaseTotal spent":"ProductCustomerSoldPaymentSale priceWarranty"; $("#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?`Showing ${start+1}–${Math.min(start+PAGE_SIZE,all.length)} of ${all.length}
Page ${state.page} of ${pages}
`:""; } function inventoryRow(p) { return `${esc(p.model)}${esc(p.manufacturer)} · ${esc(p.condition)}
${idCell(p)}
${fmtDate(p.receivedAt)}${p.cost?money.format(p.cost):"—"}Available
${state.user.role==="admin"?``:""}
`; } function saleRow(p) { return `${esc(p.model)}${esc(p.manufacturer)} · ${esc(primaryId(p))}${p.phone?`${esc(p.phone)}`:""}${fmtDate(p.soldAt)}${esc(p.paymentMethod||"—")}${p.paymentReference?`${esc(p.paymentReference)}`:""}${p.salePrice?money.format(p.salePrice):"—"}${warrantyBadge(p)}
${state.user.role==="admin"?``:""}
`; } function customerRow(c) { return `${esc(c.name)}${esc(c.phone||"—")}${c.count}${fmtDate(c.lastPurchase)}${c.total?money.format(c.total):"—"}`; } function receiveForm() { if(!state.models.length){openModal('

No active models

Add or reactivate a product model from the Admin page before receiving inventory.

');$("[data-cancel]").onclick=closeModal;return;} openModal(`

Receive inventory

Enter the shared batch details, then scan each device label. The three barcodes are added as one device at a time.

Current device

0 of 1 added

Scan the three barcodes on one label, or enter them manually. Duplicate identifiers are rejected.

No devices added yet.

`); 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)=>`
#${i+1}UID ${esc(x.uid||"—")}SN ${esc(x.sn||"—")}MAC ${esc(x.mac||"—")}
`).join(""):'

No devices added yet.

';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='
Point at the full labelHold steady while the three barcodes are read.
';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('

No available inventory

Receive a product before recording a sale.

');$("[data-cancel]").onclick=closeModal;return;} openModal(`

Record a sale

Select one or more products. Customer, payment, fulfillment, warranty, and notes apply to every selected item.

Products
${available.map(p=>``).join("")}
0 selected${money.format(0)}
${customerFields()}
`); 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='
Point at the full labelThis browser can show the camera but cannot decode barcodes automatically. Enter the three values below, then close this view.
';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?`

${fulfillmentTitle(p.fulfillmentMethod)}

${p.fulfillmentName?`

${esc(p.fulfillmentName)}

`:""}

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

${p.fulfillmentNotes?`

${esc(p.fulfillmentNotes)}

`:""}${p.fulfillmentMethod==="Shipped"?`

Carrier: ${esc(p.carrier||"Not recorded")}

Tracking: ${p.trackingNumber?(link?`${esc(p.trackingNumber)}`:esc(p.trackingNumber)):"Not entered"}

Delivery status: ${esc(p.deliveryStatus||"Not recorded")}

`:""}
`:'

Fulfillment

Not recorded

'; openModal(`

Sale record

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

Customer

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

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

Payment and sale

${esc(p.paymentMethod||"Not recorded")}${p.paymentReference?` · Reference: ${esc(p.paymentReference)}`:""}

Sale price: ${p.salePrice?money.format(p.salePrice):"—"} · Sold: ${fmtDate(p.soldAt)}

${fulfillment}

Warranty

${warrantyBadge(p)}

${p.warrantyName?`${esc(p.warrantyName)}${p.warrantyEndDate?` · Through ${fmtDate(p.warrantyEndDate)}`:""}`:"No warranty was recorded for this sale."}

Product

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

${idCell(p)}

Received: ${fmtDate(p.receivedAt)} · Cost: ${p.cost?money.format(p.cost):"—"}

${p.notes?`

Inventory notes: ${esc(p.notes)}

`:""}

Transaction notes

${esc(p.saleNotes||"No transaction notes recorded")}

${state.user.role==="admin"?'':""}
`); $("[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)?``:"";openModal(`

Edit sale details

Correct customer, payment, fulfillment, tracking, warranty, or notes.

${customerFields(p)}
`);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"?`
`:""; openModal(`

${esc(c.name)}

Customer record, support notes, and purchase history.

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

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

${c.shippingNotes?`

${esc(c.shippingNotes)}

`:""}

Customer notes

${noteForm}${c.notes.length?c.notes.map(n=>`
${esc(n.category)}${fmtDateTime(n.createdAt)}

${esc(n.note)}

${state.user.role==="admin"?``:""}
`).join(""):"

No customer notes yet.

"}

Purchases (${c.purchases.length})

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

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

${warrantyBadge(s)}

`).join("")}
`); $("[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(`

Void sale & restock

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

`); $("[data-cancel]").onclick=closeModal; $("#restockForm").onsubmit=e=>submitForm(e,`/api/products/${encodeURIComponent(p.id)}/restock`,"Product restocked."); } async function load(){ [state.products,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='
Loading administration…
'; 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=`

User accounts

Create administrators or read-only accounts. New and reset passwords must be changed at next login.

${users.map(u=>`
${esc(u.username)}${u.role==="admin"?"Admin":"Read-Only"} · ${u.enabled?"Enabled":"Disabled"}${u.mustChangePassword?" · Password change required":""}${u.lastLoginAt?` · Last login ${fmtDateTime(u.lastLoginAt)}`:""}
`).join("")}

Database backups

Create snapshots inside /data/backups, download an off-server copy, or restore a previous database.

Restore replaces the active database and all user accounts. Your current password is required. Everyone will be signed out afterward.
${backups.length?backups.map(b=>`
${esc(b.name)}${fmtDateTime(b.createdAt)} · ${(b.size/1024).toFixed(1)} KB
Download
`).join(""):"

No local backups yet.

"}

Audit log

The latest 250 security and data-changing events. Audit entries cannot be edited or deleted.

${audit.map(a=>`
${esc(a.username)}${esc(a.action.replaceAll("_"," "))}${fmtDateTime(a.createdAt)}${a.target?` · ${esc(a.target)}`:""}${a.details?` · ${esc(a.details)}`:""}${a.ipAddress?` · ${esc(a.ipAddress)}`:""}
`).join("")||"

No audit events yet.

"}
`; panel.querySelector(".admin-page").insertAdjacentHTML("afterbegin",'
vBoxStock

Administration

Manage product models, access, data protection, and account activity.

'); 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=`

System diagnostics

vBoxStock ${esc(diagnostics.appVersion)} · Database schema ${diagnostics.schemaVersion} · ${esc(diagnostics.nodeVersion)}

Database${bytes(diagnostics.databaseSize)}${diagnostics.dataWritable?"/data is writable":"/data is not writable"}
Storage available${bytes(diagnostics.diskFree)}of ${bytes(diagnostics.diskTotal)}
Backups${diagnostics.backupCount}${diagnostics.lastBackupAt?`Latest ${fmtDateTime(diagnostics.lastBackupAt)}`:"None created"}
Time zone${esc(diagnostics.timeZone)}${esc(diagnostics.dataDirectory)}
`;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=`

Automatic backups

Run a retained local backup every day or once per week. Manual, pre-upgrade, and pre-restore backups are never removed automatically.

Last automatic backup: ${diagnostics.backupSettings.lastScheduledBackup?fmtDate(diagnostics.backupSettings.lastScheduledBackup):"Not yet run"}Next automatic backup: ${diagnostics.backupSettings.nextScheduledBackup?fmtDateTime(diagnostics.backupSettings.nextScheduledBackup):"Disabled"}
`;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=`

Product models

Active models are available when receiving products. Archiving removes a model from new receiving while preserving inventory and sales history.

${models.map(m=>`
${esc(m.name)}${m.active?"Active":"Archived"}
${m.availableCount} available · ${m.soldCount} sold
${m.totalCount===0?``:""}${m.totalCount===0?``:""}
`).join("")||"

No models configured.

"}
`;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=`

Warranty periods

Choose the default offered on new sales. Used periods are preserved as sale snapshots and can be archived, but not changed or deleted.

${warranties.map(w=>`
${esc(w.name)}${w.isDefault?'Default':`${w.active?"Active":"Archived"}`}
${w.durationValue} ${esc(w.durationUnit)} · ${w.usageCount} sale${w.usageCount===1?"":"s"}
${!w.isDefault&&w.active?``:""}${w.usageCount===0?``:""}${!w.isDefault?``:""}${w.usageCount===0&&!w.isDefault?``:""}
`).join("")}
`;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='

Restore complete

The container is restarting. Everyone has been signed out.

';}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=`

Unable to load administration

${esc(e.message)}

`;} } 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='

Restore complete

The container is restarting. Everyone has been signed out.

';}catch(e){storageStatus("critical","Database error");toast(e.message);}} function showLogin(message=""){ state.user=null;document.body.className="auth-required";$("#authBody").innerHTML=`

Sign in

Use your vBoxStock account to continue.

${message?`
${esc(message)}
`:""}
`; $("#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=`

Change your password

Your temporary password must be replaced before you can use vBoxStock.

Passwords must contain at least 8 characters.
`; $("#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=`Logged in as${esc(user.username)}${user.role==="admin"?"Admin":"Read-Only"}`;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='
Point at one barcode at a timeScan UID, serial number, and MAC on the same label.
';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}); 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();