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"?"
Product
UID / SN / MAC
Received
Cost
Status
":state.tab==="customers"?"
Customer
Phone
Purchases
Last purchase
Total spent
":"
Product
Customer
Sold
Payment
Sale price
Warranty
";
$("#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}
';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('
Select one or more products. Customer, payment, fulfillment, warranty, and notes apply to every selected item.
`);
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"}
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)}
`;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.
`;}
}
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=`
Your temporary password must be replaced before you can use vBoxStock.
`;
$("#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});
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();