import {money,cents,units,calculate,linePrice,newId,mayOffline,retryDelay} from './core.mjs'; import {sendEpos,receiptLines,paymentLabel} from './epos.mjs'; import * as store from './storage.mjs'; import {apiRequest} from './api.mjs'; import {deliverOperation} from './delivery.mjs'; import {ClientError} from './sync.mjs'; const syncOwner=newId(); const $=id=>document.getElementById(id); const state={user:null,csrf:'',boot:null,cart:[],category:'All',search:'',view:'pos',terminal:'',permit:null,online:navigator.onLine,storage:false,busy:false,report:null,admin:null,manage:'catalog',inventorySearch:'',inventoryScope:'all',syncing:false}; const manager=()=>['manager','admin'].includes(state.user?.role); const admin=()=>state.user?.role==='admin'; function element(tag,attrs={},...children){ const node=document.createElement(tag); for(const [key,value] of Object.entries(attrs)){ if(key==='class') node.className=value; else if(key==='text') node.textContent=value; else if(key.startsWith('on')) node.addEventListener(key.slice(2),value); else if(key in node && !['form','list','role'].includes(key)) node[key]=value; else node.setAttribute(key,value); } for(const child of children.flat()) if(child!=null) node.append(child instanceof Node?child:document.createTextNode(String(child))); return node; } const button=(label,action,cls='')=>element('button',{type:'button',class:cls,text:label,onclick:()=>run(action)}); function passwordVisibility(control,revealed){ control.type=revealed?'text':'password'; const toggle=control.parentElement.querySelector('.password-toggle'); toggle.setAttribute('aria-label',revealed?'Hide password':'Show password'); toggle.setAttribute('aria-pressed',String(revealed)); toggle.querySelector('img').src=revealed?'/assets/eye-off.svg':'/assets/eye.svg'; } const label=(name,control)=>{ if(control.type!=='password')return element('label',{},name,control); control.id ||= 'password-'+newId();control.dataset.password='true'; const toggle=element('button',{type:'button',class:'password-toggle','aria-label':'Show password','aria-pressed':'false','aria-controls':control.id,onclick:()=>passwordVisibility(control,control.type==='password')},element('img',{src:'/assets/eye.svg',alt:'','aria-hidden':'true',width:22,height:22})); return element('div',{class:'password-field'},element('label',{htmlFor:control.id,text:name}),element('div',{class:'password-control'},control,toggle)); }; const input=(name,value='',type='text',extra={})=>element('input',{name,value,type,...extra}); const select=(name,options,value)=>element('select',{name},options.map(([v,t])=>element('option',{value:v,text:t,selected:v===value}))); const empty=message=>element('div',{class:'empty',text:message}); function notify(message,error=false){ $('notice').textContent=message; $('notice').classList.toggle('error',error); $('notice').hidden=false; clearTimeout(notify.timer); notify.timer=setTimeout(()=>$('notice').hidden=true,error?10000:5000); } async function run(work){try{return await work();}catch(e){notify(e.message,true);}} function modal(title,content){$('modalTitle').textContent=title;$('modalContent').replaceChildren(content);if(!$('modal').open)$('modal').showModal();} function closeModal(){ $('modal').close(); } function signInRequired(){ $('login').hidden=false;$('app').hidden=true;closeModal();$('loginError').textContent='Sign in again to continue. Drafts and pending work are preserved.'; } async function request(path,{method='GET',body,key,actor=state.user?.id,csrfRetried=false}={}){ const abort=new AbortController();const timer=setTimeout(()=>abort.abort(),15000); try{ const data=await apiRequest(path,{method,body,key,actor,csrf:state.csrf,signal:abort.signal}); state.online=true;connection();return data; }catch(e){ if(e.code==='csrf_mismatch'&&!csrfRetried){ const session=await request('/api/session'); if(path==='/api/login'||(actor&&session.user?.id===actor)){state.csrf=session.csrf;return request(path,{method,body,key,actor,csrfRetried:true});} e=new ClientError('Sign in again to synchronize saved work.',{status:401,code:'authentication_required'}); } if(e.status===401&&state.user&&path!=='/api/login'){ signInRequired(); } if(e.network){state.online=false;connection();}throw e; }finally{clearTimeout(timer);} } function connection(){ $('connection').textContent=state.syncing?'Syncing':state.online?'Online':'Offline';$('connection').classList.toggle('offline',!state.online); } function ownShift(){return state.boot?.shifts.find(s=>s.terminal_id===state.terminal&&s.staff_id===state.user.id);} async function refreshBoot(){ const previous=state.boot; state.boot=await request('/api/bootstrap');state.user=state.boot.user; // A draft keeps its displayed catalog/rules until confirmation or explicit clearing. if(previous&&state.cart.length&&previous.user.id===state.user.id){ for(const key of ['products','rules','catalog_token','catalog_expires'])state.boot[key]=previous[key]; } if(!state.boot.terminals.some(t=>t.id===state.terminal))state.terminal=state.boot.terminals[0]?.id||''; await store.put('bootstrap',state.boot);await store.put('terminal',state.terminal); $('staffName').textContent=`${state.user.name} · ${state.user.role}`; $('demoBanner').hidden=!state.boot.demo;$('manageNav').hidden=!manager();$('reportsNav').hidden=!manager(); $('terminalSelect').replaceChildren(...state.boot.terminals.map(t=>element('option',{value:t.id,text:t.name,selected:t.id===state.terminal}))); renderDiscountAccess();renderMenu();renderCart();renderShift(); } async function renewPermit(){ if(!state.online||!ownShift())return; const p=await request('/api/offline-permit',{method:'POST',body:{terminal_id:state.terminal},key:newId()}); state.permit={...p,used:0,terminal:state.terminal,user:state.user.id};await store.put('permit',state.permit); } async function persistCart(){ const catalog=Object.fromEntries(['products','rules','catalog_token','catalog_expires'].map(key=>[key,state.boot[key]])); const meta={orderType:$('orderType').value,discount:$('discount').value,reason:$('discountReason').value}; state.draftRevision=await store.saveDraft(state.user.id,state.cart,meta,catalog,state.draftRevision||0); } let draftWrites=Promise.resolve(); function cartChange(work){const result=draftWrites.then(async()=>{const previous=structuredClone(state.cart);try{work();await persistCart();renderCart();}catch(e){state.cart=previous;renderCart();throw e;}});draftWrites=result.catch(()=>{});return result;} function product(id){return state.boot.products.find(p=>p.id===id);} function validLine(l){const p=product(l.product_id);return p&&l.modifiers.every(id=>p.modifiers.some(m=>m.id===id));} function totals(){return calculate(state.cart.filter(validLine).map(l=>({...l,unit_price:linePrice(product(l.product_id),l.modifiers)})),state.boot.rules,$('discount').value);} function renderMenu(){ const cats=['All',...new Set(state.boot.products.map(p=>p.category))]; if(!cats.includes(state.category))state.category='All'; $('categories').replaceChildren(...cats.map(c=>button(c,()=>{state.category=c;renderMenu();},c===state.category?'active':''))); const visible=state.boot.products.filter(p=>(state.category==='All'||p.category===state.category)&&`${p.name} ${p.description}`.toLowerCase().includes(state.search.toLowerCase())); const stock=new Map((state.boot.inventory_products||[]).map(p=>[p.id,p])); $('menuGrid').replaceChildren(...visible.map(p=>{ const status=stock.get(p.id)?.stock_status||'unknown';const unavailable=status==='out_of_stock';const low=status==='low_stock'; return element('button',{class:`product-card ${unavailable?'stock-empty':low?'stock-low':''}`,type:'button',disabled:unavailable,onclick:()=>run(()=>customize(p))},element('h3',{text:p.name}),element('p',{text:p.description}),...(unavailable||low?[element('span',{class:`stock-badge ${unavailable?'empty':'low'}`,text:unavailable?'Out of stock':'Low stock · notify manager'})]:[]),element('div',{class:'product-bottom'},element('span',{class:'price',text:money(p.price)}),element('span',{class:'plus-dot','aria-hidden':'true'}))); })); if(!visible.length)$('menuGrid').append(empty(state.boot.products.length?'No items match your search.':'No active products. Ask an administrator to configure the menu.')); } function customize(p,line){ const form=element('form',{class:'form-stack'}); const qty=input('qty',String(line?.qty||1),'number',{min:1,max:100,required:true});form.append(label('Quantity',qty)); const names=Object.fromEntries(state.boot.ingredients.map(i=>[i.id,`${i.name} (${i.unit})`])); if(p.recipe.length){form.append(element('h3',{text:'Ingredients & optional sauces'}));for(const r of p.recipe){ const selected=r.optional?(line?.optional||[]).includes(r.ingredient_id):!(line?.omit||[]).includes(r.ingredient_id); form.append(element('label',{class:'check'},input(`recipe:${r.ingredient_id}`,'yes','checkbox',{checked:selected}),`${names[r.ingredient_id]||'Ingredient'}${r.optional?' · optional':''}`)); }} if(p.modifiers.length){form.append(element('h3',{text:'Extras'}));for(const m of p.modifiers)form.append(element('label',{class:'check'},input(`modifier:${m.id}`,'yes','checkbox',{checked:(line?.modifiers||[]).includes(m.id)}),`${m.name} +${money(m.price)}`));} const note=element('textarea',{name:'note',value:line?.note||'',maxLength:200,placeholder:'Preparation notes'});form.append(label('Notes',note)); if(manager())form.append(element('label',{class:'check'},input('eligible','yes','checkbox',{checked:line?.eligible===true}),'Eligible line for verified senior/PWD consumption')); form.append(element('button',{type:'submit',class:'primary',text:line?'Save item':'Add to order'})); form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{ const n=Number(qty.value);if(!Number.isInteger(n)||n<1||n>100)throw Error('Quantity must be 1–100.'); const data=new FormData(form);const next={key:line?.key||newId(),product_id:p.id,qty:n,modifiers:p.modifiers.filter(m=>data.has(`modifier:${m.id}`)).map(m=>m.id),optional:p.recipe.filter(r=>r.optional&&data.has(`recipe:${r.ingredient_id}`)).map(r=>r.ingredient_id),omit:p.recipe.filter(r=>!r.optional&&!data.has(`recipe:${r.ingredient_id}`)).map(r=>r.ingredient_id),eligible:data.has('eligible'),name:p.name,note:note.value}; await cartChange(()=>{if(line)state.cart[state.cart.findIndex(l=>l.key===line.key)]=next;else{if(state.cart.length>=100)throw Error('Order has 100 lines.');state.cart.push(next);}});closeModal(); });});modal(p.name,form); } function renderDiscountAccess(){ const allowed=manager();$('discount').disabled=!allowed; $('discount').setAttribute('aria-describedby','discountAccessNote'); const note=$('discountAccessNote');note.textContent=!allowed?'Discounts require a manager or administrator. Ask them to apply the approved discount.':!state.boot?.rules.legal_approved?'Discount rules need administrator approval before use.':'';note.hidden=!note.textContent; } function renderCart(){ if(!state.boot)return; const invalid=state.cart.some(l=>!validLine(l)); $('discountReasonWrap').hidden=$('discount').value==='none'; $('cartLines').replaceChildren(...state.cart.map(l=>{ const p=product(l.product_id)||{name:l.name||'Unavailable item',modifiers:[]};return element('div',{class:'cart-line'},element('div',{class:'line-top'},element('span',{class:'line-name',text:p.name}),element('strong',{text:validLine(l)?money(linePrice(p,l.modifiers)*l.qty):'Unavailable'})),element('p',{class:'muted',text:[...l.modifiers.map(id=>p.modifiers.find(m=>m.id===id)?.name),l.note,l.eligible?'Eligible consumption':''].filter(Boolean).join(' · ')}),element('div',{class:'line-controls'},button('−',()=>cartChange(()=>{if(l.qty>1)l.qty--;else state.cart=state.cart.filter(x=>x.key!==l.key);})),element('span',{text:l.qty}),button('+',()=>cartChange(()=>{if(l.qty>=100)throw Error('Maximum quantity is 100.');l.qty++;})),button('Edit',()=>{if(!product(l.product_id))throw Error('Product is unavailable. Remove this line or ask a manager.');customize(p,l);}),button('✕',()=>cartChange(()=>state.cart=state.cart.filter(x=>x.key!==l.key)),'remove danger'))); })); if(!state.cart.length)$('cartLines').append(empty('Your next great order starts here. Choose an item from the menu.')); const t=totals();$('totals').replaceChildren(...[['Subtotal',t.subtotal],['Discount / VAT exemption',-t.discount],['VAT included',t.tax],['Service charge',t.service],['Total',t.total]].map(([name,v],i)=>element('div',{class:`total-row ${i===4?'grand':''}`},element('span',{text:name}),element('span',{text:money(v)})))); const disabled=invalid||!state.cart.length||!ownShift()||state.busy||!state.storage; $('checkoutBtn').disabled=disabled||state.user.role==='server'||t.total===0;$('prepareBtn').disabled=disabled||!state.online; $('prepareBtn').textContent=t.total===0&&state.cart.length?'Confirm complimentary order':'Send to preparation · unpaid'; $('shiftHint').textContent=invalid?'An item or extra is unavailable. Remove or edit it before confirming.':ownShift()?'Prices and stock are validated when the order is confirmed.':'Open your shift before confirming orders.'; $('mobileCount').textContent=state.cart.reduce((n,l)=>n+l.qty,0);$('mobileTotal').textContent=money(t.total);$('mobileCart').hidden=!state.cart.length||state.view!=='pos'; } async function mutate(path,payload,options={}){ if(state.mutationActive)throw Error('An operation is being saved. Wait for it to finish.'); state.mutationActive=true;try{return await mutateInternal(path,payload,options);}finally{state.mutationActive=false;} } async function mutateInternal(path,payload,{sale=false}={}){ if(!state.storage)throw Error('Durable storage is required.'); if(!state.online&&!sale)throw Error('This operation requires connectivity.'); if(['/api/staff','/api/staff-update'].includes(path))return request(path,{method:'POST',body:payload,key:newId()}); const existing=(await store.queue()).find(r=>r.user===state.user.id&&r.path===path&&JSON.stringify(r.payload)===JSON.stringify(payload)); if(existing)throw Error('This operation is already saved. Use Pending to retry it.'); let record={id:newId(),user:state.user.id,path,payload,status:'Pending',created:Date.now(),attempts:0,draftRevision:state.draftRevision||0}; if(sale){record=await store.commitSale(record,`cart:${state.user.id}`);state.draftRevision=(state.draftRevision||0)+1;state.cart=[];renderCart();$('ticket').classList.remove('open');} else record=await store.enqueue(record); await updateQueue(); if(!state.online){notify('Saved on this terminal. Synchronization is pending.');return null;} return await deliver(record); } async function deliver(record,{manual=false}={}){ try{const outcome=await deliverOperation(record,{store,owner:syncOwner,request,manual});await updateQueue();if(outcome.state!=='Acknowledged')notify('Operation remains saved. Earlier work or another tab may need to finish first.');return outcome.result;} finally{await updateQueue();} } async function updateQueue(){const records=(await store.queue()).filter(r=>r.user===state.user?.id);$('pendingCount').textContent=records.length;return records;} async function sync(){ if(state.syncing||!state.user||!navigator.onLine)return;state.syncing=true;connection(); try{ const session=await request('/api/session');if(!session.user||session.user.id!==state.user.id){signInRequired();return;}state.csrf=session.csrf; const records=await updateQueue(); for(const r of records){if(r.status==='Needs Review')break;const result=await deliver(r);if(!result)break;} if(!(await updateQueue()).length){await refreshBoot();if(!state.cart.length&&(!state.permit||state.permit.expires{if(!state.online)throw Error('Reconnect first.');const result=await deliver(r,{manual:true});if(result){notify('Operation synchronized.');await refreshBoot();await pendingDialog();}}))); for(const r of records)if(r.path==='/api/orders'&&r.status==='Needs Review'&&(r.errorCode==='catalog_changed'||r.error==='Prices or rules changed. Review the saved order before resubmitting.'))wrap.append(button(`Review saved order ${r.id.slice(0,8)} with current prices`,()=>reviewSavedOrder(r),'primary')); wrap.append(button('Export pending work',()=>download('hungry-pita-pending.json',JSON.stringify(records,null,2),'application/json')));modal('Synchronization',wrap); } async function reviewSavedOrder(record){ if(!state.online)throw Error('Reconnect to review current prices.'); await draftWrites; if(state.cart.length)throw Error('Finish or clear your current draft before reviewing this saved order.'); const check=await request(`/api/order-review?order_id=${encodeURIComponent(record.payload.order_id)}&request_id=${encodeURIComponent(record.id)}`); if(check.unposted!==true)throw Error('Could not confirm this order is unposted. Keep it saved.'); await refreshBoot(); const catalog=Object.fromEntries(['products','rules','catalog_token','catalog_expires'].map(key=>[key,state.boot[key]])); state.draftRevision=await store.restoreRejectedSale(record,catalog,state.draftRevision||0); state.cart=structuredClone(record.payload.lines);$('orderType').value=record.payload.order_type||'Dine-in';$('discount').value=record.payload.discount||'none';$('discountReason').value=record.payload.discount_reason||''; closeModal();await updateQueue();await view('pos');renderCart(); modal('Review updated order',element('div',{class:'form-stack'},element('p',{text:`Previous total: ${money(record.payload.expected_total)}. Current total: ${money(totals().total)}.`}),element('p',{text:'Review quantities, unavailable items and the updated total before confirming again. The original saved operation is archived on this terminal. No payment was posted for this rejected order.'}),...(record.payload.payments?.length?[element('p',{class:'muted',text:'If cash or GCash was already collected, reconcile it before confirming the revised payment. The previous payment is not automatically submitted.'})]:[]),button('Review items',closeModal,'primary'))); } function offlineAllowed(total){return state.permit?.user===state.user.id&&state.permit?.terminal===state.terminal&&mayOffline({permitExpires:state.permit.expires,catalogExpires:state.boot.catalog_expires,remaining:state.permit.cash_limit-state.permit.used,total,discount:$('discount').value,method:'cash'});} async function createOrder(payments){ if(state.busy)return;state.busy=true;renderCart(); try{ await draftWrites; const total=totals().total;if(total<0)throw Error('Order total is invalid.');const offline=!state.online; if(offline&&!offlineAllowed(total))throw Error('Offline cash authorization expired or its allowance is insufficient. Reconnect or ask a manager.'); const payload={order_id:newId(),expected_total:total,terminal_id:state.terminal,lines:structuredClone(state.cart),discount:$('discount').value,discount_reason:$('discountReason').value,order_type:$('orderType').value,payments,offline,permit:state.permit?.permit,catalog_token:state.boot.catalog_token,created_at:Math.floor(Date.now()/1000)}; const result=await mutate('/api/orders',payload,{sale:true});if(offline)state.permit=await store.get('permit');closeModal();$('discount').value='none';$('discountReason').value=''; if(result){notify('Order confirmed. Stock consumption recorded.');showOrder(result.order);}else notify('Order saved locally. Keep this terminal’s storage intact until synchronized.'); }catch(e){if(e.code==='catalog_changed')await pendingDialog();throw e;}finally{state.busy=false;renderCart();} } function checkout(existing){ const due=existing?(existing.selectedBill?existing.selectedBill.total-existing.selectedBill.paid:existing.total-existing.paid):totals().total;if(due<=0)throw Error('Nothing remains to pay.'); const form=element('form',{class:'form-stack'});form.append(element('div',{class:'modal-summary',text:money(due)})); const method=select('method',[['cash','Cash'],['gcash','GCash / QR · externally verified'],['card','Credit card / external machine']],'cash'); method.setAttribute('aria-label','Payment method'); const cardType=select('card_type',[['','Select card type'],['mastercard','Mastercard'],['visa','Visa'],['other','Other']],'');cardType.setAttribute('aria-label','Card type');const cardWrap=label('Card type',cardType); const amount=input('amount',(due/100).toFixed(2),'text',{inputMode:'decimal',required:true});const tender=input('tender',(due/100).toFixed(2),'text',{inputMode:'decimal',required:true}); const reference=input('reference','','text',{maxLength:100});const verified=input('verified','yes','checkbox'); reference.setAttribute('aria-label','Transaction reference'); const referenceTitle=element('span',{text:'Transaction reference'});const referenceWrap=element('label',{},referenceTitle,reference);const verifyText=element('span');const verifyWrap=element('label',{class:'check'},verified,verifyText);const change=element('p',{class:'muted'}); const update=()=>{const external=method.value!=='cash';const card=method.value==='card';referenceWrap.hidden=verifyWrap.hidden=!external;cardWrap.hidden=!card;reference.required=verified.required=external;cardType.required=card;tender.parentElement.hidden=external;referenceTitle.textContent=card?'Card machine transaction reference':'GCash transaction reference';verifyText.textContent=card?'The card machine approved this payment.':'I verified receipt of the GCash payment externally.';try{change.textContent=card?'Complete the charge on the card machine, then enter its reference. Enter no card number, expiry or security code.':external?'A QR code alone does not confirm payment.':`Change: ${money(Math.max(0,cents(tender.value)-cents(amount.value)))}`;}catch{change.textContent='Enter a valid amount.';}}; form.append(label('Payment method',method),cardWrap,label(existing?'Amount to allocate (partial payments allowed)':'Payment amount',amount),label('Cash tendered',tender),referenceWrap,verifyWrap,change);if(!existing)amount.readOnly=true; const submit=element('button',{type:'submit',class:'primary',text:existing?'Record payment':'Confirm payment & order'});form.append(submit);method.addEventListener('change',()=>{verified.checked=false;reference.value='';cardType.value='';update();});tender.addEventListener('input',update);amount.addEventListener('input',update);update(); form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{submit.disabled=true;try{ const n=cents(amount.value);if(n<=0||n>due)throw Error('Payment must be within the remaining amount.'); const p={method:method.value,amount:n,tendered:method.value==='cash'?cents(tender.value):n,reference:method.value==='cash'?'':reference.value,verified:method.value==='cash'?false:verified.checked,...(method.value==='card'?{card_type:cardType.value}:{})}; if(p.method==='card'&&!p.card_type)throw Error('Select a card type.');if(p.tenderedelement('button',{class:'order-card',onclick:()=>run(async()=>showOrder((await request(`/api/orders?id=${o.id}`)).order))},element('strong',{text:`Order ${o.id.slice(0,8)}`}),element('span',{class:'badge',text:o.state}),element('span',{text:`${money(Number(o.total))} · ${o.payment_state}`}),element('span',{class:'muted',text:new Date(o.created_at.replace(' ','T')+'Z').toLocaleString('en-PH')}))));if(!data.orders.length)$('ordersList').append(empty('No orders yet.'));} function showOrder(order){ const wrap=element('div',{class:'form-stack'});const s=order.snapshot;wrap.append(element('p',{class:'badge',text:`${order.state} · ${order.payment_state}`}),element('div',{class:'modal-summary',text:money(order.total)})); for(const l of s.lines)wrap.append(element('div',{class:'total-row'},element('span',{text:`${l.qty} × ${l.name}`}),element('span',{text:money(l.total)}))); wrap.append(element('p',{class:'muted',text:`Paid ${money(order.paid)} · Refunded ${money(order.refunded)} · Stock consumption remains recorded.`})); for(const p of order.payments)wrap.append(element('p',{class:'muted',text:`${paymentLabel(p)}: ${money(p.amount)}${p.reference?' - Ref: '+p.reference:''}`})); const actions=element('div',{class:'button-row'});const next={'In Preparation':'Ready','Ready':'Served/Completed','Served/Completed':'Closed'}[order.state]; if(next){const advance=button(`Mark ${next}`,async()=>{advance.disabled=true;try{const r=await mutate('/api/order-state',{order_id:order.id,version:order.version,state:next,reason:''});if(r){showOrder(r.order);if(r.already_applied)notify('Order already reached this status. Current status loaded.');}}catch(e){if(['order_changed','invalid_order_transition'].includes(e.code)){const fresh=await request(`/api/orders?id=${order.id}`);showOrder(fresh.order);notify('Order status changed. Current order loaded; review Pending if work remains.',true);}else throw e;}finally{advance.disabled=false;}},'primary');actions.append(advance);} if(order.paidb.paidcheckout({...order,selectedBill:bill})));} if(!order.paid&&!['Closed','Voided','Merged'].includes(order.state)){actions.append(button('Split bill',()=>splitDialog(order)),button('Merge check',()=>mergeDialog(order)));} if(manager()&&order.paid>order.refunded)actions.append(button('Refund',()=>refundDialog(order),'danger')); if(manager()&&['In Preparation','Ready'].includes(order.state))actions.append(button('Void order',()=>reasonDialog('Void order','Consumed ingredients will not be restored automatically.',async reason=>{const r=await mutate('/api/order-state',{order_id:order.id,version:order.version,state:'Voided',reason});if(r)showOrder(r.order);}),'danger')); if(manager())actions.append(button('Reprint receipt',()=>reasonDialog('Receipt reprint','Reprints are labelled and audited.',async reason=>{const r=await mutate('/api/reprint',{order_id:order.id,reason});if(r)showOrder(r.order);})));wrap.append(actions); if(manager()&&!order.paid&&['In Preparation','Ready'].includes(order.state)&&s.lines.length>1){for(const [index,line] of s.lines.entries())wrap.append(button('Void item: '+line.name,()=>reasonDialog('Void unpaid item','Prepared stock remains consumed. Bill allocations will reset.',async reason=>{const r=await mutate('/api/void-item',{order_id:order.id,version:order.version,line_index:index,reason});if(r)showOrder(r.order);}), 'danger'));} wrap.append(element('h3',{text:'Print jobs'})); for(const job of order.print_jobs){const panel=element('div',{class:'panel'},element('strong',{text:`${job.kind} · ${job.state}`})); const endpoint=state.boot.printers?.[state.terminal]; if(job.state==='Queued'&&endpoint)panel.append(button('Print to Epson TM-P80II',async()=>{const saved=await mutate('/api/print-state',{id:job.id,state:'Submitted',reason:''});if(!saved)return;const outcome=await sendEpos(endpoint,receiptLines(order,state.boot.business_name,job.kind),job.id);await mutate('/api/print-state',{id:job.id,...outcome});showOrder((await request('/api/orders?id='+order.id)).order);})); if(job.state==='Queued')panel.append(button('Open browser print',async()=>{const r=await mutate('/api/print-state',{id:job.id,state:'Submitted',reason:''});if(!r)return;printOrder(order,job.kind);closeModal();window.print();const unknown=await mutate('/api/print-state',{id:job.id,state:'Unknown',reason:'Browser print cannot confirm physical delivery.'});if(unknown)showOrder((await request(`/api/orders?id=${order.id}`)).order);})); if(['Submitted','Unknown'].includes(job.state))panel.append(button('Confirm printed',async()=>{const r=await mutate('/api/print-state',{id:job.id,state:'Confirmed',reason:''});if(r)showOrder((await request(`/api/orders?id=${order.id}`)).order);}));wrap.append(panel); } wrap.append(element('p',{class:'muted',text:'Epson TM-P80II direct printing is available after secure endpoint setup and hardware validation. Browser printing is the fallback.'}));modal(`Order ${order.id.slice(0,8)}`,wrap); } function reasonDialog(title,description,action){const form=element('form',{class:'form-stack'},element('p',{class:'muted',text:description}));const reason=element('textarea',{required:true,maxLength:300});form.append(label('Reason',reason),element('button',{type:'submit',class:'primary',text:'Confirm'}));form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{if(!reason.value.trim())throw Error('Reason required.');await action(reason.value);});});modal(title,form);} function refundDialog(order){ const form=element('form',{class:'form-stack'});const options=order.payments.map(p=>[p.id,`${paymentLabel(p)} · ${money(p.amount)} · ${p.reference||'cash'}`]);const payment=select('payment',options,options[0]?.[0]);const amount=input('amount','','text',{inputMode:'decimal',required:true});const reason=element('textarea',{required:true,maxLength:300});const reference=input('reference','','text',{maxLength:100});const settled=input('settled','yes','checkbox'); form.append(label('Original payment',payment),label('Refund amount',amount),label('Reason',reason),label('External refund reference (required for GCash/card)',reference),element('label',{class:'check'},settled,'I completed the cash/external refund.'),element('p',{class:'muted',text:'Refunds are recorded separately. Ingredient consumption is preserved.'}),element('button',{type:'submit',class:'primary',text:'Record settled refund'})); form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{const r=await mutate('/api/refunds',{order_id:order.id,version:order.version,terminal_id:state.terminal,payment_id:payment.value,amount:cents(amount.value),reason:reason.value,reference:reference.value,settled:settled.checked});if(r)showOrder(r.order);});});modal('Refund',form); } function printOrder(order,kind){ const area=$('printArea');area.className='print-area';const s=order.snapshot;area.replaceChildren(element('h1',{text:state.boot.business_name}),element('p',{text:kind==='kitchen'?'PREPARATION TICKET':kind==='reprint'?'REPRINT · TRANSACTION RECORD':'TRANSACTION RECORD'}),element('p',{text:'NOT A BIR INVOICE'}),element('p',{text:`Order ${order.id}\n${order.created_at} UTC\n${order.order_type}`})); for(const l of s.lines)area.append(element('p',{text:`${l.qty} × ${l.name} ${money(l.total)}\n${[...l.modifiers,...(l.omitted_names||[]).map(n=>'No '+n),...(l.optional_names||[]).map(n=>'With '+n),l.note].filter(Boolean).join(' · ')}`}));for(const [name,value] of [['Subtotal',s.subtotal],['Discount / exemption',s.discount],['VAT included',s.tax],['Service',s.service],['TOTAL',s.total]])area.append(element('div',{class:'total-row'},name,money(value)));area.append(element('p',{text:order.payments.map(p=>`${paymentLabel(p)}: ${money(p.amount)}${p.reference?' - Ref: '+p.reference:''}`).join('\n')})); } function renderShift(){ const own=ownShift();const other=state.boot.shifts.find(s=>s.terminal_id===state.terminal);const wrap=$('shiftContent');wrap.replaceChildren(); if(own){wrap.append(element('div',{class:'panel'},element('h3',{text:'Shift open'}),element('p',{text:`Opening cash ${money(Number(own.opening_cash))}`}),element('p',{class:'muted',text:`Offline cash authorization: ${state.permit?new Date(state.permit.expires*1000).toLocaleTimeString():'not issued'}. Synchronize pending work before closing.`}),button('X reading',()=>showReading(own.id)),button('Refresh offline authorization',async()=>{if((await updateQueue()).length)throw Error('Synchronize pending work first.');await renewPermit();renderShift();}),button('Close shift / Z reading',()=>{ const form=element('form',{class:'form-stack'});const counted=input('counted','','text',{inputMode:'decimal',required:true});form.append(label('Counted cash in drawer',counted),element('button',{type:'submit',class:'primary',text:'Close shift'}));form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{if((await updateQueue()).length)throw Error('Synchronize pending work before closing.');const r=await mutate('/api/shifts',{action:'close',terminal_id:state.terminal,counted_cash:cents(counted.value)});if(r){closeModal();state.permit=null;await store.put('permit',null);await refreshBoot();notify('Shift closed. Review the reconciliation in Management.');}});});modal('Close shift',form); })));} else if(other)wrap.append(empty('Another staff member’s shift is open here. Select another terminal or have them close it.')); else{const form=element('form',{class:'form-stack panel'});const opening=input('opening','0.00','text',{inputMode:'decimal',required:true});form.append(label('Opening cash',opening),element('button',{type:'submit',class:'primary',text:'Open shift'}));form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{const r=await mutate('/api/shifts',{action:'open',terminal_id:state.terminal,opening_cash:cents(opening.value)});if(r){await refreshBoot();await renewPermit();renderShift();notify('Shift opened. Ready to take orders.');}});});wrap.append(form);} } function table(headers,rows){ const head=element('thead',{},element('tr',{},headers.map(h=>element('th',{text:h})))); const body=element('tbody',{},rows.map(row=>element('tr',{},row.map(c=>element('td',{},c))))); return element('div',{class:'table-wrap'},element('table',{},head,body)); } async function loadManage(){state.admin=await request('/api/admin');renderManage();} function renderManage(){ if(!state.admin){$('manageContent').replaceChildren(element('p',{class:'muted',role:'status',text:'Loading inventory and management data…'}));return;} const data=state.admin;const wrap=$('manageContent');wrap.replaceChildren();for(const btn of $('manageTabs').querySelectorAll('button'))btn.classList.toggle('active',btn.dataset.manage===state.manage); if(state.manage==='catalog'){if(manager())wrap.append(button('Add product / editable combo',()=>productEditor()));wrap.append(table(['Product','Category','Price','Status',''],data.products.map(p=>[p.name,p.category,money(p.price),p.active?'Active':'Out of stock',manager()?button('Edit',()=>productEditor(p)):''])));} else if(state.manage==='stock'){ if(admin())wrap.append(button('Add stock item',()=>ingredientDialog())); const search=input('inventorySearch',state.inventorySearch,'search',{placeholder:'Search by name',maxLength:120,autocomplete:'off'}); const scope=select('inventoryScope',[['all','Products and stock items'],['products','Store products'],['items','Stock items']],state.inventoryScope); scope.setAttribute('aria-label','Search in'); const results=element('div',{id:'inventoryResults'}); search.addEventListener('input',()=>{state.inventorySearch=search.value;renderInventoryResults();}); scope.addEventListener('change',()=>{state.inventoryScope=scope.value;renderInventoryResults();}); wrap.append(element('div',{class:'inventory-search'},label('Search inventory',search),label('Search in',scope),button('Clear search',()=>{state.inventorySearch='';state.inventoryScope='all';search.value='';scope.value='all';renderInventoryResults();search.focus();})),element('p',{id:'inventoryFreshness',class:'muted',text:`Live stock: ${data.inventory_updated_at||''} UTC. Offline sales deduct when synchronized.`}),results); renderInventoryResults(); } else if(state.manage==='rules'){ const rules=data.rules;const form=element('form',{class:'form-stack panel'});const fields={};for(const [key,title] of [['vat_bps','VAT % (inclusive pricing)'],['statutory_bps','Senior/PWD discount %'],['promo_bps','Promotion discount %'],['service_bps','Service charge %']]){fields[key]=input(key,String(rules[key]/100),'text',{inputMode:'decimal',required:true,disabled:!admin()});form.append(label(title,fields[key]));} const promoCode=input('promo_code',rules.promo_code||'','text',{maxLength:32,disabled:!admin()});form.append(label('Promotion code',promoCode)); const legal=input('legal','yes','checkbox',{checked:rules.legal_approved,disabled:!admin()});const reason=input('reason','','text',{maxLength:300,required:true,disabled:!admin()});form.append(element('label',{class:'check'},legal,'Accountant approved these rules for this business.'),element('p',{class:'muted',text:'VAT-inclusive pricing. Eligibility applies to marked lines. Verify registration, group meals and statutory rules before operational use.'}),label('Change reason',reason));if(admin())form.append(element('button',{type:'submit',class:'primary',text:'Save new rules version'})); form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{const payload={version:rules.version,reason:reason.value,promo_code:promoCode.value,legal_approved:legal.checked};for(const [key,f] of Object.entries(fields))payload[key]=cents(f.value);const r=await mutate('/api/rules',payload);if(r){await loadManage();await refreshBoot();notify('Rules version saved. Historical orders preserved.');}});});wrap.append(form); }else if(state.manage==='staff'){if(admin())wrap.append(button('Create staff account',()=>staffDialog()));wrap.append(table(['Name','Username','Role','Status',''],data.staff.map(s=>[s.name,s.username,s.role,Number(s.active)===1?'Active':'Inactive',admin()?button('Edit access',()=>accessDialog(s)):''])));} else{wrap.append(element('h3',{text:'Shift reconciliation'}),table(['Opened','Status','Opening cash','Counted cash',''],data.shifts.map(s=>[s.opened_at,s.status,money(Number(s.opening_cash)),s.counted_cash===null?'—':money(Number(s.counted_cash)),button('X / Z reading',()=>showReading(s.id))])),element('h3',{text:'Latest 100 audit events'}),table(['Time UTC','Action','Staff','Details'],data.audit.map(a=>[a.created_at,a.action,a.staff_id.slice(0,8),element('pre',{text:a.details})])));} } function renderInventoryResults(){ const wrap=$('inventoryResults');if(!wrap||!state.admin)return; const data=state.admin;const query=state.inventorySearch.trim().toLocaleLowerCase('en'); const matches=name=>String(name).toLocaleLowerCase('en').includes(query); const products=(data.inventory_products||[]).filter(p=>matches(p.name)); const items=data.ingredients.filter(i=>matches(i.name)); wrap.replaceChildren();let count=0; if(state.inventoryScope!=='items'){ count+=products.length;wrap.append(element('h3',{text:`Store products (${products.length})`}),products.length?table(['Store product','Tracking','Available'],products.map(p=>[p.name,p.stock_mode,p.available===null?'Configure recipe':String(p.available)])):element('p',{class:'muted',text:'No store products match your search.'})); } if(state.inventoryScope!=='products'){ count+=items.length;wrap.append(element('h3',{text:`Stock items (${items.length})`}),items.length?table(['Stock item','Category','Available','Reorder level',''],items.map(i=>[i.name,i.kind||'ingredient',`${Number(i.quantity)/1000} ${i.unit}`,`${Number(i.threshold)/1000} ${i.unit}`,button(Number(i.quantity)<=Number(i.threshold)?'Low stock · adjust':'Adjust',()=>stockDialog(i))])):element('p',{class:'muted',text:'No stock items match your search.'})); } wrap.prepend(element('p',{class:'muted',role:'status','aria-live':'polite',text:`${count} matching ${count===1?'entry':'entries'}`})); const freshness=$('inventoryFreshness');if(freshness)freshness.textContent=`Live stock: ${data.inventory_updated_at||''} UTC. Offline sales deduct when synchronized.`; } function recipeInputs(recipe=[]){ const wrap=element('div',{class:'form-stack'});const rows=[]; for(const i of state.admin.ingredients){const found=recipe.find(r=>r.ingredient_id===i.id);const qty=input(i.id,found?String(found.quantity/1000):'0','text',{inputMode:'decimal'});const optional=input('optional','yes','checkbox',{checked:found?.optional===true});wrap.append(element('div',{class:'recipe-line'},element('span',{text:`${i.name} · ${i.unit}`}),qty,element('label',{class:'check'},optional,'Optional')));rows.push({i,qty,optional});} return {node:wrap,read:()=>rows.map(r=>({ingredient_id:r.i.id,quantity:units(r.qty.value),optional:r.optional.checked})).filter(r=>r.quantity!==0)}; } function productEditor(p){ const form=element('form',{class:'form-stack'});const name=input('name',p?.name||'','text',{required:true,maxLength:120});const category=input('category',p?.category||'Wraps','text',{required:true,maxLength:80});const price=input('price',p?String(p.price/100):'','text',{inputMode:'decimal',required:true});const description=element('textarea',{value:p?.description||'',maxLength:500});const active=input('active','yes','checkbox',{checked:p?.active===1}); form.append(label('Product / combo name',name),label('Category',category),label('Price ₱',price),label('Description',description),element('label',{class:'check'},active,'Available for ordering'),element('h3',{text:'Recipe quantities per item / combo'}),element('p',{class:'muted',text:'Use the declared ingredient unit. Enter full combo quantities including packaging. Zero means unused. Optional ingredients consume stock only when selected.'}));const mode=select('stock_mode',[['recipe','Deduct recipe components'],['direct','Deduct one piece of own stock per sale']],p?.stock_mode||'recipe');const stockItem=select('stock_item_id',[['','Create own stock item'],...state.admin.ingredients.filter(i=>i.unit==='pcs').map(i=>[i.id,i.name])],p?.stock_item_id||'');form.append(label('Stock tracking',mode),label('Direct stock item',stockItem));const recipe=recipeInputs(p?.recipe);form.append(recipe.node);recipe.node.hidden=mode.value==='direct';mode.addEventListener('change',()=>{recipe.node.hidden=mode.value==='direct';}); const modifiersWrap=element('div',{class:'form-stack'});const modifierForms=[]; const addModifier=m=>{const panel=element('div',{class:'panel form-stack'});const n=input('name',m?.name||'','text',{maxLength:80,required:true});const price=input('price',m?String(m.price/100):'0','text',{inputMode:'decimal'});const recipe=recipeInputs(m?.recipe);const remove=input('remove','yes','checkbox');panel.append(label('Extra name',n),label('Extra price ₱',price),recipe.node,element('label',{class:'check'},remove,'Remove this extra'));modifiersWrap.append(panel);modifierForms.push({id:m?.id||newId(),name:n,price,recipe,remove});}; for(const m of p?.modifiers||[])addModifier(m);form.append(element('h3',{text:'Extras / modifiers'}),modifiersWrap,button('Add extra',()=>addModifier()),element('button',{type:'submit',class:'primary',text:'Save product version'})); form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{const payload={...(p?{id:p.id,version:p.version}:{}),name:name.value,category:category.value,price:cents(price.value),description:description.value,active:active.checked?1:0,stock_mode:mode.value,...(mode.value==='direct'&&stockItem.value?{stock_item_id:stockItem.value}:{}),recipe:mode.value==='direct'?[]:recipe.read(),modifiers:modifierForms.filter(m=>!m.remove.checked).map(m=>({id:m.id,name:m.name.value,price:cents(m.price.value),recipe:m.recipe.read()}))};const r=await mutate('/api/products',payload);if(r){closeModal();await loadManage();await refreshBoot();notify('Product version saved.');}});});modal(p?'Edit product / combo':'New product / combo',form); } function ingredientDialog(){const form=element('form',{class:'form-stack'});const name=input('name','','text',{required:true,maxLength:100});const unit=select('unit',[['pcs','Pieces (fractional pieces allowed)'],['grams','Grams'],['ml','Milliliters']],'pcs');const kind=select('kind',[['ingredient','Ingredient'],['product','Store product'],['packaging','Packaging'],['supply','Store supply']],'product');const threshold=input('threshold','0','text',{inputMode:'decimal'});form.append(label('Stock item name',name),label('Category',kind),label('Stock unit',unit),label('Reorder threshold',threshold),element('button',{type:'submit',class:'primary',text:'Create stock item'}));form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{const r=await mutate('/api/ingredients',{name:name.value,kind:kind.value,unit:unit.value,threshold:units(threshold.value)});if(r){closeModal();await loadManage();await refreshBoot();}});});modal('New inventory item',form);} function stockDialog(i){const form=element('form',{class:'form-stack'});const kind=select('kind',[['receiving','Receiving (+)'],['wastage','Wastage (−)'],['spoilage','Spoilage (−)'],['correction','Correction (signed)'],['return','Actual return (+)']],'receiving');const quantity=input('quantity','','text',{inputMode:'decimal',required:true});const reason=element('textarea',{required:true,maxLength:300});form.append(element('p',{text:`Available: ${Number(i.quantity)/1000} ${i.unit}`}),label('Movement type',kind),label(`Quantity in ${i.unit}`,quantity),label('Reason',reason),element('button',{type:'submit',class:'primary',text:'Post movement'}));form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{let n=units(quantity.value);if(['wastage','spoilage'].includes(kind.value))n=-Math.abs(n);const r=await mutate('/api/stock',{ingredient_id:i.id,quantity:n,kind:kind.value,reason:reason.value});if(r){closeModal();await loadManage();await refreshBoot();}});});modal(i.name,form);} function staffDialog(){const form=element('form',{class:'form-stack'});const name=input('name','','text',{required:true,maxLength:100});const username=input('username','','text',{required:true,maxLength:80,autocomplete:'off'});const password=input('password','','password',{required:true,minLength:12,maxLength:200,autocomplete:'new-password'});const role=select('role',[['cashier','Cashier'],['server','Server'],['manager','Manager'],['admin','Admin']],'cashier');form.append(label('Name',name),label('Username',username),label('Password · at least 12 characters',password),label('Role',role),element('button',{type:'submit',class:'primary',text:'Create staff account'}));form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{const r=await mutate('/api/staff',{name:name.value,username:username.value,password:password.value,role:role.value});if(r){closeModal();await loadManage();}});});modal('New staff account',form);} async function report(form){const data=new FormData(form);state.report=await request(`/api/reports?start=${encodeURIComponent(data.get('start'))}&end=${encodeURIComponent(data.get('end'))}`);const r=state.report;$('reportContent').replaceChildren(element('div',{class:'stats'},Object.entries(r.summary).map(([key,value])=>element('div',{class:'stat'},element('span',{class:'eyebrow',text:key.replaceAll('_',' ')}),element('strong',{text:['count','voids','item_voids','comps'].includes(key)?value:money(value)})))),element('p',{class:'muted',text:r.note}),element('h3',{text:'Sales by item'}),table(['Item','Quantity','Total'],r.items.map(i=>[i.name,i.quantity,money(i.total)])),element('h3',{text:'Sales by category'}),table(['Category','Quantity','Total'],r.categories.map(i=>[i.name,i.quantity,money(i.total)])),element('h3',{text:'Daily sales'}),table(['Date','Billed total'],r.days.map(i=>[i.date,money(i.total)])));} function download(name,content,type){const url=URL.createObjectURL(new Blob([content],{type}));element('a',{href:url,download:name}).click();setTimeout(()=>URL.revokeObjectURL(url),1000);} function csvCell(value){const s=String(value);return '"'+(/^[=+\-@\t\r]/.test(s)?"'":'')+s.replaceAll('"','""')+'"';} async function view(name){state.view=name;for(const v of ['pos','orders','manage','reports','shift'])$(`${v}View`).hidden=v!==name;for(const b of document.querySelectorAll('[data-view]'))b.classList.toggle('active',b.dataset.view===name);renderCart();if(name==='orders')await loadOrders();if(name==='manage')await loadManage();if(name==='shift')renderShift();} async function enter(boot,offline=false){ state.boot=boot;state.user=boot.user;state.terminal=await store.get('terminal')||boot.terminals[0]?.id||'';state.permit=await store.get('permit');state.cart=await store.get(`cart:${state.user.id}`)||[]; if(boot.api_version!==undefined&&boot.api_version!==1)throw Error('POS version needs an update before synchronizing.'); state.draftRevision=await store.get(`draft-revision:${state.user.id}`)||0; const meta=await store.get(`draft-meta:${state.user.id}`);const catalog=await store.get(`draft-catalog:${state.user.id}`); if(state.cart.length&&catalog){state.boot={...boot,...catalog};} if(meta){$('orderType').value=meta.orderType;$('discount').value=meta.discount;$('discountReason').value=meta.reason;} if(!boot.terminals.some(t=>t.id===state.terminal))state.terminal=boot.terminals[0]?.id||''; if(state.permit?.user!==state.user.id||state.permit?.terminal!==state.terminal)state.permit=null; if(offline&&(!state.permit||state.permit.expires<=Date.now()/1000))throw Error('Reconnect and sign in before using this terminal. Saved work is retained.'); $('login').hidden=true;$('app').hidden=false;$('staffName').textContent=`${state.user.name} · ${state.user.role}`;$('manageNav').hidden=!manager();$('reportsNav').hidden=!manager();renderDiscountAccess();$('demoBanner').hidden=!boot.demo; $('terminalSelect').replaceChildren(...boot.terminals.map(t=>element('option',{value:t.id,text:t.name,selected:t.id===state.terminal})));state.online=!offline;connection();renderMenu();renderCart();renderShift();await updateQueue();if(!offline&&(await updateQueue()).length===0)await renewPermit(); } const loginPassword=$('loginForm').querySelector('input[name=password]'); loginPassword.closest('label').replaceWith(label('Password',loginPassword)); document.addEventListener('reset',e=>{for(const control of e.target.querySelectorAll('[data-password]'))passwordVisibility(control,false);},true); $('modalClose').addEventListener('click',closeModal); $('search').addEventListener('input',e=>{state.search=e.target.value;renderMenu();}); for(const id of ['discount','orderType','discountReason'])$(id).addEventListener('change',()=>run(()=>cartChange(()=>{}))); $('checkoutBtn').addEventListener('click',()=>run(()=>checkout()));$('prepareBtn').addEventListener('click',()=>run(async()=>{if(!state.online)throw Error('Unpaid orders require connectivity.');await createOrder([]);})); $('clearCart').addEventListener('click',()=>run(()=>reasonDialog('Clear draft','This clears an unconfirmed local draft. Posted orders use the audited void workflow.',async()=>{await cartChange(()=>state.cart=[]);closeModal();}))); $('mobileCart').addEventListener('click',()=>$('ticket').classList.add('open'));$('closeTicket').addEventListener('click',()=>$('ticket').classList.remove('open')); $('syncBtn').addEventListener('click',()=>run(pendingDialog));$('refreshOrders').addEventListener('click',()=>run(loadOrders));$('refreshManage').addEventListener('click',()=>run(loadManage)); for(const b of document.querySelectorAll('[data-view]'))b.addEventListener('click',()=>run(()=>view(b.dataset.view))); for(const b of document.querySelectorAll('[data-manage]'))b.addEventListener('click',()=>{state.manage=b.dataset.manage;renderManage();}); $('terminalSelect').addEventListener('change',()=>run(async()=>{if((await updateQueue()).length||state.cart.length){$('terminalSelect').value=state.terminal;throw Error('Finish draft and pending work before changing terminals.');}state.terminal=$('terminalSelect').value;await store.put('terminal',state.terminal);state.permit=null;await store.put('permit',null);renderCart();renderShift();await renewPermit();})); $('reportForm').addEventListener('submit',e=>{e.preventDefault();run(()=>report(e.target));}); $('exportCsv').addEventListener('click',()=>run(()=>{if(!state.report)throw Error('Generate a report first.');const rows=[['Metric','Centavos / count'],...Object.entries(state.report.summary),[],['Item','Quantity','Total centavos'],...state.report.items.map(i=>[i.name,i.quantity,i.total]),[],['Category','Quantity','Total centavos'],...state.report.categories.map(i=>[i.name,i.quantity,i.total]),[],['Date','Billed total centavos'],...state.report.days.map(i=>[i.date,i.total])];download('hungry-pita-report.csv','\uFEFF'+rows.map(r=>r.map(csvCell).join(',')).join('\r\n'),'text/csv;charset=utf-8');})); $('printReport').addEventListener('click',()=>run(()=>{if(!state.report)throw Error('Generate a report first.');$('printArea').className='print-area report';$('printArea').replaceChildren(element('h1',{text:'Hungry Pita sales report'}),element('p',{text:`${state.report.start} to ${state.report.end}`}),$('reportContent').cloneNode(true));window.print();})); $('logoutBtn').addEventListener('click',()=>run(async()=>{if((await updateQueue()).length||state.cart.length)throw Error('Resolve draft and pending work before signing out.');await request('/api/logout',{method:'POST',body:{}});await store.put('bootstrap',null);await store.put('permit',null);location.reload();})); $('loginForm').addEventListener('submit',e=>{e.preventDefault();run(async()=>{const submit=e.target.querySelector('button[type=submit]');submit.disabled=true;$('loginError').textContent='';try{if(!state.storage)throw Error('Durable browser storage is unavailable.');const data=new FormData(e.target);const session=await request('/api/session');state.csrf=session.csrf;const result=await request('/api/login',{method:'POST',body:{username:data.get('username'),password:data.get('password')}});state.csrf=result.csrf;state.user=result.user;state.cart=[];state.boot=null;await refreshBoot();await enter(state.boot);e.target.reset();}catch(err){$('loginError').textContent=err.message;}finally{submit.disabled=false;}});}); window.addEventListener('online',()=>{state.online=true;connection();run(sync);});window.addEventListener('offline',()=>{state.online=false;connection();}); window.addEventListener('beforeunload',e=>{if(state.cart.length){e.preventDefault();e.returnValue='';}}); for(const field of $('reportForm').querySelectorAll('input'))field.value=new Date().toLocaleDateString('en-CA',{timeZone:'Asia/Manila'}); async function start(){ if(window.THP_UNSUPPORTED)return; try{await store.openStore();state.storage=true;}catch(e){$('loginError').textContent=e.message;return;} try{const session=await request('/api/session');state.csrf=session.csrf;if(session.user){state.user=session.user;await refreshBoot();await enter(state.boot);}} catch(e){const boot=await store.get('bootstrap');const p=await store.get('permit');if(e.network&&boot&&p&&p.user===boot.user.id&&p.expires>Date.now()/1000)await enter(boot,true);else $('loginError').textContent=e.message;} if('serviceWorker'in navigator)navigator.serviceWorker.register('/sw.js').catch(()=>notify('Offline application cache unavailable. Keep connectivity until resolved.',true)); setInterval(()=>{if(state.user&&document.visibilityState==='visible')run(sync);},15000); } start().catch(e=>notify(e.message,true)); function splitDialog(order){ const form=element('form',{class:'form-stack'});const mode=select('mode',[['guests','Equal split by guest'],['items','Assign items to bills']],'guests');const count=input('count','2','number',{required:true,min:2,max:20});const allocations=element('div');let rows=[]; const render=()=>{allocations.replaceChildren();rows=[];if(mode.value!=='items')return;const n=Number(count.value);if(!Number.isInteger(n)||n<2||n>20)return; for(const [i,line] of order.snapshot.lines.entries()){const controls=[];const panel=element('div',{class:'panel'},element('strong',{text:`${line.qty} × ${line.name}`}));for(let g=0;g{e.preventDefault();run(async()=>{const n=Number(count.value);const payload={order_id:order.id,version:order.version,mode:mode.value,guests:n};if(mode.value==='items')payload.groups=Array.from({length:n},(_,g)=>rows.map(row=>Number(row[g].value)));const r=await mutate('/api/split-bill',payload);if(r)showOrder(r.order);});});modal('Split bill',form); } async function mergeDialog(order){ const candidates=(await request('/api/orders')).orders.filter(o=>o.id!==order.id&&o.payment_state==='Unpaid'&&!['Closed','Voided','Merged'].includes(o.state)); if(!candidates.length)throw Error('No other unpaid active checks to merge.'); const form=element('form',{class:'form-stack'});const source=select('source',candidates.map(o=>[o.id,`${o.id.slice(0,8)} · ${money(Number(o.total))}`]),candidates[0].id);form.append(label('Check to merge into this order',source),element('p',{class:'muted',text:'Checks must use the same shift, terminal and pricing rules. Original stock consumption is preserved.'}),element('button',{type:'submit',class:'primary',text:'Merge checks'})); form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{const candidate=candidates.find(o=>o.id===source.value);const r=await mutate('/api/merge-checks',{order_id:order.id,version:order.version,source_id:source.value,source_version:Number(candidate.version)});if(r)showOrder(r.order);});});modal('Merge checks',form); } async function showReading(id){ const r=(await request(`/api/shift-reading?id=${id}`)).reading;const wrap=element('div',{class:'form-stack'},element('p',{class:'badge',text:r.status}));for(const [key,value] of Object.entries(r))if(!['shift_id','type','status'].includes(key))wrap.append(element('div',{class:'total-row'},key.replaceAll('_',' '),value===null?'—':money(value))); wrap.append(button('Print / save reading',()=>{$('printArea').className='print-area';$('printArea').replaceChildren(element('h1',{text:r.type}),element('p',{text:`Shift ${id}`}),wrap.cloneNode(true));window.print();}));modal(r.type,wrap); } function accessDialog(staff){ const form=element('form',{class:'form-stack'});const name=input('name',staff.name,'text',{required:true,maxLength:100});const role=select('role',[['cashier','Cashier'],['server','Server'],['manager','Manager'],['admin','Admin']],staff.role);const active=input('active','yes','checkbox',{checked:Number(staff.active)===1});const password=input('password','','password',{minLength:12,maxLength:200,autocomplete:'new-password'});const reason=element('textarea',{required:true,maxLength:300});form.append(label('Name',name),label('Role',role),element('label',{class:'check'},active,'Account enabled'),label('New password (optional)',password),label('Reason',reason),element('p',{class:'muted',text:'Changes revoke existing online sessions. Offline permissions remain bounded by their expiry.'}),element('button',{type:'submit',class:'primary',text:'Save access changes'})); form.addEventListener('submit',e=>{e.preventDefault();run(async()=>{const r=await mutate('/api/staff-update',{id:staff.id,name:name.value,role:role.value,active:active.checked?1:0,password:password.value,reason:reason.value});if(r){closeModal();await loadManage();notify('Access updated. Staff must sign in again.');}});});modal('Staff access',form); } let inventoryRefreshing=false; setInterval(async()=>{ if(inventoryRefreshing||!state.online||!state.user||state.view!=='manage'||state.manage!=='stock'||document.hidden)return; inventoryRefreshing=true; try{const data=await request('/api/inventory');if(state.admin){Object.assign(state.admin,data);if(state.view==='manage'&&state.manage==='stock')renderInventoryResults();}} catch(e){const status=document.getElementById('inventoryFreshness');if(status)status.textContent='Stock refresh unavailable. Showing last confirmed counts; refresh before adjusting.';} finally{inventoryRefreshing=false;} },5000); let productStockRefreshing=false; setInterval(async()=>{ if(productStockRefreshing||!state.online||!state.user||!state.boot||state.view!=='pos'||document.hidden)return; productStockRefreshing=true; try{const data=await request('/api/product-stock');state.boot.inventory_products=data.inventory_products;await store.put('bootstrap',state.boot);if(state.view==='pos')renderMenu();} catch(e){/* Preserve the last confirmed stock snapshot during connection failures. */} finally{productStockRefreshing=false;} },5000);