Secure Cloudflare POS Workspace Studio

Bilingual management with zero credentials exposed inside client views

1. Choose / Recall Workspace Target

2. Shop Profile Specifications

📷 Click or drag image to upload

Stored in repo · shown in POS header and receipt

QR code with bill total appears on each receipt

Staff PIN to access menu editing on the POS terminal

Controls how VAT is shown on receipts

3. Menu Structural Configuration

Supported columns: Category, Item, Price_THB (or Price), Description

🏪 Deployed POS Shops

Click Refresh to load deployed shops

4. Secure Deployment Terminal

Transfers payload through Cloudflare Workers directly to GitHub.

5. System JSON Manifest

[]
i.src)}))}">
${logoTag}

${H(m.bizName)}

0.00 ${c}

No paid bills yet.

`; /* ── Generated script ──────────────────────────────────────────── */ const script = [ // Embedded constants (set at build time) `const SLUG="${slug}";`, `const SHOP_PIN="${pin}";`, `const LOGO_URL="${logo}";`, `const PP_ID="${pp}";`, `const CURRENCY="${c}";`, `const VAT_MODE="${vat}";`, // "inclusive" | "exclusive" | "none" // Menu data — fetched live on load, falls back to embedded snapshot `let l="EN",f="ALL",dataset=${menuJson};`, // Receipt label dictionaries 'const dict={', ` EN:{title:"RECEIPT / GUEST CHECK",tax:"Tax ID:",sub:"Subtotal",vatIncl:"incl. VAT 7%",vatExtra:"VAT (7%)",sc:"Service Charge",disc:"Discount",total:"TOTAL (${c})",payment:"Payment:"},`, ` TH:{title:"ใบเสร็จรับเงิน",tax:"เลขประจำตัวผู้เสียภาษี:",sub:"รวมยอด",vatIncl:"รวม VAT 7% แล้ว",vatExtra:"ภาษีมูลค่าเพิ่ม (7%)",sc:"ค่าบริการ",disc:"ส่วนลด",total:"ยอดรวมสุทธิ (${c})",payment:"ช่องทางชำระ:"}`, '};', // UI text dictionaries (updates visible labels when language switches) 'const uiDict={', ' EN:{orderTab:"🧾 Order",invoiceTab:"🖨️ Invoice",histTab:"📋 History",addItem:"➕ Add Item",disc:"Discount",sc:"Service",pm:"Payment",print:"🖨️ Print Receipt",paidCash:"💵 Cash Paid",paidPp:"📱 PromptPay Paid",', ' histAll:"All (30 days)",histToday:"Today",histWeek:"This Week",histMonth:"This Month",histTotal:"Total",histBills:"bills",histNone:"No paid bills in this period.",histDelConfirm:"Delete this bill from history?",histDel:"Delete",histView:"👁️ View"},', ' TH:{orderTab:"🧾 คำสั่งซื้อ",invoiceTab:"🖨️ ใบแจ้งหนี้",histTab:"📋 ประวัติ",addItem:"➕ เพิ่มรายการ",disc:"ส่วนลด",sc:"ค่าบริการ",pm:"ชำระเงิน",print:"🖨️ พิมพ์ใบเสร็จ",paidCash:"💵 เงินสด",paidPp:"📱 พร้อมเพย์",', ' histAll:"ทั้งหมด (30 วัน)",histToday:"วันนี้",histWeek:"สัปดาห์นี้",histMonth:"เดือนนี้",histTotal:"ยอดรวม",histBills:"บิล",histNone:"ไม่มีบิลที่ชำระในช่วงนี้",histDelConfirm:"ลบบิลนี้ออกจากประวัติ?",histDel:"ลบ",histView:"👁️ ดู"}', '};', // HTML-escape for every piece of user text that goes into innerHTML / document.write `function esc(s){return String(s==null?"":s).replace(/[&<>"']/g,function(c){return{"&":"&","<":"<",">":">",'"':""","'":"'"}[c];});}`, // Fetch live menu from server on load (updates embedded snapshot silently) // Returns a resolved promise so callers can .then() after dataset is ready 'async function loadLiveMenu(){', ' try{', ' const r=await fetch(`/data/${SLUG}-menu.json`,{cache:"no-store"});', ' if(r.ok){dataset=await r.json();fb();up();}', ' }catch(e){}', ' return true;', '}', // Tab switcher 'function m(p){', ' document.getElementById("pCalc").className=p==="calc"?"panel active":"panel";', ' document.getElementById("pDoc").className=p==="doc"?"panel active":"panel";', ' document.getElementById("pHist").className=p==="hist"?"panel active":"panel";', ' document.getElementById("b1").className=p==="calc"?"active":"";', ' document.getElementById("b2").className=p==="doc"?"active":"";', ' document.getElementById("b3").className=p==="hist"?"active":"";', ' if(p==="doc"){calc();renderBills();pullKind("bills").then(ok=>{if(ok)renderBills();});}', ' if(p==="hist"){renderPaidBills();pullKind("paid").then(ok=>{if(ok)renderPaidBills();});}', '}', // Language switcher — updates ALL visible UI strings immediately 'function sl(lang){', ' l=lang;', ' document.getElementById("lEN").className=l==="EN"?"lang-btn active":"lang-btn";', ' document.getElementById("lTH").className=l==="TH"?"lang-btn active":"lang-btn";', ' const ui=uiDict[l];', ' document.getElementById("b1").textContent=ui.orderTab;', ' document.getElementById("b2").textContent=ui.invoiceTab;', ' document.getElementById("addBtn").textContent=ui.addItem;', ' document.getElementById("lDisc").textContent=ui.disc;', ' document.getElementById("lSc").textContent=ui.sc;', ' document.getElementById("lPm").textContent=ui.pm;', ' document.getElementById("printBtn").textContent=ui.print;', ' document.getElementById("paidCashBtn").textContent=ui.paidCash;', ' document.getElementById("paidPpBtn").textContent=ui.paidPp;', ' document.getElementById("b3").textContent=ui.histTab;', ' document.getElementById("fOptAll").textContent=ui.histAll;', ' document.getElementById("fOptToday").textContent=ui.histToday;', ' document.getElementById("fOptWeek").textContent=ui.histWeek;', ' document.getElementById("fOptMonth").textContent=ui.histMonth;', ' up();calc();', ' if(document.getElementById("pHist").className.indexOf("active")>-1)renderPaidBills();', '}', // Category filter chips 'function fb(){', ' const bar=document.getElementById("fBar");', ' const cats=["ALL",...new Set(dataset.map(d=>d.category).filter(Boolean))];', ' bar.innerHTML="";', ' cats.forEach(c=>{', ' const b=document.createElement("button");b.type="button";', ' b.className="chip"+(f===c?" active":"");b.textContent=c;', ' b.onclick=()=>{f=c;fb();up();};', ' bar.appendChild(b);', ' });', '}', // Rebuild dropdown options in current language. // `forceAll` bypasses the active category filter chip — required when // restoring saved bills / drafts so items saved under any category can // be re-selected even if the user is currently filtering by another one. 'function up(forceAll){', ' const showAll=forceAll||f==="ALL";', ' document.querySelectorAll(".item-row select").forEach(s=>{', ' const cur=s.value;', ' s.innerHTML=``;', ' dataset.forEach(d=>{', ' if(showAll||d.category===f||d.id===cur){', ' const o=document.createElement("option");o.value=d.id;', ' o.text=(l==="EN"?d.nameEN:d.nameTH)+` (${CURRENCY}${d.price})`;', ' s.appendChild(o);', ' }', ' });', ' s.value=cur;', ' });', '}', // Restore one item row from a saved bill/draft. Returns true if the // item id could be resolved against the (possibly live) dataset. 'function applyRow(row,r){', ' const sel=row.querySelector("select");', ' let item=dataset.find(x=>x.id===r.id);', ' // Item id no longer exists in dataset (menu edit / live reload) —', ' // inject a synthetic option from the saved snapshot so the row is', ' // never silently dropped on recall.', ' if(!item){', ' item={id:r.id,nameEN:r.nameEN||"Unknown",nameTH:r.nameTH||"ไม่ทราบ",price:r.price||0};', ' const o=document.createElement("option");o.value=item.id;', ' o.text=(l==="EN"?item.nameEN:item.nameTH)+` (${CURRENCY}${item.price})`;', ' sel.appendChild(o);', ' }', ' sel.value=r.id;', ' sel.dataset.price=item.price;', ' row.querySelector(".q").value=r.qty;', '}', // Add order row with qty stepper 'function add(){', ' const id="r_"+Math.random().toString(36).substr(2,5);', ' const row=document.createElement("div");row.className="item-row";row.id=id;', ' const sel=document.createElement("select");sel.onchange=()=>hc(id);', ' const step=document.createElement("div");step.className="qty-stepper";', ' const bM=document.createElement("button");bM.type="button";bM.textContent="−";', ' bM.onclick=()=>{const q=row.querySelector(".q");q.value=Math.max(1,(parseInt(q.value)||1)-1);calc();};', ' const qIn=document.createElement("input");qIn.type="number";qIn.className="q";qIn.value="1";qIn.min="1";qIn.oninput=calc;', ' const bP=document.createElement("button");bP.type="button";bP.textContent="+";', ' bP.onclick=()=>{const q=row.querySelector(".q");q.value=(parseInt(q.value)||0)+1;calc();};', ' step.append(bM,qIn,bP);', ' const del=document.createElement("button");del.type="button";del.className="del-btn";del.textContent="✕";', ' del.onclick=()=>{row.remove();calc();};', ' row.append(sel,step,del);', ' document.getElementById("lines").appendChild(row);', '}', // Add an order row without rebuilding every select (use up() once after a batch) 'function addNoUpd(){', ' const id="r_"+Math.random().toString(36).substr(2,5);', ' const row=document.createElement("div");row.className="item-row";row.id=id;', ' const sel=document.createElement("select");sel.onchange=()=>hc(id);', ' const step=document.createElement("div");step.className="qty-stepper";', ' const bM=document.createElement("button");bM.type="button";bM.textContent="−";', ' bM.onclick=()=>{const q=row.querySelector(".q");q.value=Math.max(1,(parseInt(q.value)||1)-1);calc();};', ' const qIn=document.createElement("input");qIn.type="number";qIn.className="q";qIn.value="1";qIn.min="1";qIn.oninput=calc;', ' const bP=document.createElement("button");bP.type="button";bP.textContent="+";', ' bP.onclick=()=>{const q=row.querySelector(".q");q.value=(parseInt(q.value)||0)+1;calc();};', ' step.append(bM,qIn,bP);', ' const del=document.createElement("button");del.type="button";del.className="del-btn";del.textContent="✕";', ' del.onclick=()=>{row.remove();calc();};', ' row.append(sel,step,del);', ' document.getElementById("lines").appendChild(row);', ' return row;', '}', 'function add(){ addNoUpd(); up(); }', 'function hc(id){', ' const sel=document.getElementById(id).querySelector("select");', ' const item=dataset.find(d=>d.id===sel.value);', ' sel.dataset.price=item?item.price:0;calc();', '}', // PromptPay EMV QR payload builder (Tag 30 merchant info + Tag 54 amount + Tag 63 CRC) // Bug fix: CRC must be computed over the payload WITHOUT the "6304" placeholder, // and the Tag 54 length must count only the amount string (e.g. "100.00" => "06"). 'function ppPayload(rawId,amount){', ' const id=rawId.replace(/\\D/g,"");if(!id)return"";', ' let acc=id,subTag="02";', ' if(id.length===10){acc="0066"+id.slice(1);subTag="01";}', ' const aid="0016A000000677010111";', ' const sub=subTag+String(acc.length).padStart(2,"0")+acc;', ' const miAidAndSub=aid+sub;', ' const mi="29"+String(miAidAndSub.length).padStart(2,"0")+miAidAndSub;', ' const amtStr=amount.toFixed(2);', ' const amt="54"+String(amtStr.length).padStart(2,"0")+amtStr;', ' const noCrc="000201010212"+mi+"5802TH5303764"+amt+"6304";', ' return noCrc+crc16(noCrc);', '}', 'function crc16(s){', ' let c=0xFFFF;', ' for(let i=0;i{', ' const sel=row.querySelector("select");', ' const qty=parseInt(row.querySelector(".q").value)||0;', ' const pr=parseFloat(sel.dataset.price)||0;', ' if(sel.value&&qty>0){', ' const item=dataset.find(d=>d.id===sel.value);', ' if(item){sub+=qty*pr;items.push({nEN:item.nameEN,nTH:item.nameTH,q:qty,p:pr,t:qty*pr});}', ' }', ' });', ' const disc=parseFloat(document.getElementById("disc").value)||0;', ' const scPct=parseFloat(document.getElementById("sc").value)||0;', ' const dVal=sub*disc/100;', ' const afterDisc=sub-dVal;', ' const sVal=afterDisc*scPct/100;', ' const afterSc=afterDisc+sVal;', // VAT calculation depends on mode ' let vat=0,total=0;', ' if(VAT_MODE==="inclusive"){total=afterSc;vat=total*7/107;}', ' else if(VAT_MODE==="exclusive"){vat=afterSc*0.07;total=afterSc+vat;}', ' else{total=afterSc;vat=0;}', ' document.getElementById("totalBox").textContent=Math.round(total).toLocaleString()+".00 "+CURRENCY;', ' const labels=dict[l];', ' const dStr=new Date().toLocaleString("en-GB");', ' const tab=document.getElementById("tab").value;', ' const note=document.getElementById("note").value;', ' const pm=document.getElementById("pm").value;', // Receipt rows ' let rows="";', ' items.forEach(i=>{', ' rows+=`', ' ${esc(l==="EN"?i.nEN:i.nTH)}', ' ${i.q}', ' ${Math.round(i.p)}.00', ' ${Math.round(i.t)}.00', ' `;', ' });', // VAT line in receipt (mode-aware) ' let vatLine="";', ' if(VAT_MODE==="inclusive"&&vat>0){', ' vatLine=`${labels.vatIncl}(${Math.round(vat)}.00)`;', ' } else if(VAT_MODE==="exclusive"&&vat>0){', ' vatLine=`${labels.vatExtra}+${Math.round(vat)}.00`;', ' }', // PromptPay QR ' const ppData=PP_ID?ppPayload(PP_ID,total):"";', // QR is drawn locally from /assets/qr.js (no third-party QR service); falls back to text only ' let qrSvg="";', ' try{if(ppData&&window.QR)qrSvg=window.QR.svg(ppData,200);}catch(e){}', ' const ppSection=ppData?`', '
', '
${qrSvg}
', '
พร้อมเพย์ · PromptPay · ${PP_ID}
', '
${CURRENCY}${Math.round(total)}.00
', '
`:"";', // Logo in receipt ` const logoHtml=LOGO_URL?\`
logo
\`:"";`, // Full srcdoc receipt ' const empty=`-- Empty --`;', ' const doc=`', ' ${logoHtml}', ` `, ` `, ' ', '
${hs(m.bizName)}
`, ` ${hs(m.bizTax)}
${hs(m.bizAddress)}
${hs(m.bizTel)}
${labels.title}
', ' Ref: REC-CF · ${dStr}
', '
', ' 📍 ${esc(tab)}👥 ${esc(note)}', '
', ' ', ' ', ' ', ' ', ' ', ' ', ' ${rows||empty}', '
ItemQtyUnitTotal
', ' ', ' ', ' ${dVal>0?``:""}', ' ${sVal>0?``:""}', ' ${vatLine}', ' ', ' ', '
${labels.sub}${Math.round(sub)}.00
${labels.disc}-${Math.round(dVal)}.00
${labels.sc}+${Math.round(sVal)}.00
${labels.total}${Math.round(total)}.00
', '
${labels.payment} ${esc(pm)}
', ' ${ppSection}', ' `;', ' document.querySelector("iframe").srcdoc=doc;', ' saveDraft();', '}', /* ── Edit modal ────────────────────────────────────────── */ 'let editItems=[];', 'function openEdit(){', ' const pin=prompt("🔐 Enter staff PIN:");', ' if(pin===null)return;', ' if(pin!==SHOP_PIN){alert("❌ Incorrect PIN.");return;}', ' editItems=JSON.parse(JSON.stringify(dataset));', ' renderEditList();', ' document.getElementById("editModal").classList.add("open");', ' document.getElementById("saveStatus").className="save-status";', '}', 'function closeEdit(){', ' document.getElementById("editModal").classList.remove("open");', '}', // Get all unique categories for the category dropdown in edit mode 'function getCategories(){', ' return [...new Set(editItems.map(d=>d.category).filter(Boolean))];', '}', 'function renderEditList(){', ' const list=document.getElementById("editList");', ' list.innerHTML="";', ' const cats=getCategories();', ' editItems.forEach((item,i)=>{', ' const card=document.createElement("div");card.className="edit-item-card";', ' const catOptions=cats.map(c=>``).join("");', ' card.innerHTML=`', '
', '
', ' ', ' ', ' ', '
`,', ' list.appendChild(card);', ' });', '}', 'function addEditItem(){', ' const cats=getCategories();', ' editItems.push({id:"item_"+Math.random().toString(36).substr(2,5),nameEN:"",nameTH:"",price:0,category:cats[0]||""});', ' renderEditList();', '}', // Save edited menu back to GitHub via /api/deploy 'async function saveMenu(){', ' const status=document.getElementById("saveStatus");', ' status.className="save-status";', ' try{', ' const menuJson=JSON.stringify(editItems,null,2);', // Reconstruct menuStructure for manifest ' const catMap={};', ' editItems.forEach(item=>{', ' if(!catMap[item.category])catMap[item.category]={id:"cat_"+Date.now()+Math.random().toString(36).substr(2,3),categoryName:item.category,visible:true,items:[]};', ' catMap[item.category].items.push({id:item.id,nameEN:item.nameEN,nameTH:item.nameTH,price:item.price});', ' });', ` const manifest={bizName:"${n}",bizTax:"${tx}",bizAddress:"${ad}",bizTel:"${ph}",bizCurrency:CURRENCY,bizLogo:LOGO_URL,bizPromptpay:PP_ID,bizPin:SHOP_PIN,bizVatMode:VAT_MODE,menuStructure:Object.values(catMap)};`, ' const apiPost=(path,content,msg)=>fetch("/api/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({shopSlug:SLUG,path,content,message:msg})});', ' const r1=await apiPost(`data/${SLUG}-menu.json`,menuJson,"pos(edit): menu update from POS terminal");', ' const r2=await apiPost(`data/${SLUG}-manifest.json`,JSON.stringify(manifest,null,2),"pos(edit): manifest sync from POS terminal");', ' if(r1.ok&&r2.ok){', ' dataset=editItems;fb();up();calc();', ' status.textContent="✅ Saved! Menu updated on server.";', ' status.className="save-status ok";', ' setTimeout(closeEdit,1500);', ' }else{', ' status.textContent="❌ Save failed. Check server connection.";', ' status.className="save-status err";', ' }', ' }catch(e){', ' status.textContent="❌ "+e.message;', ' status.className="save-status err";', ' }', '}', // ── Bills / paid-bill storage with merge-sync ─────────────────────────── // Every record has {id, updatedAt}. Deleting writes a tombstone ({deleted:true}) so the // delete reaches the other devices instead of the bill coming back. Client and server merge // by id, newest updatedAt wins, so two devices never overwrite each other's bills. 'const KEYS={bills:"pos_bills_"+SLUG,paid:"pos_paid_"+SLUG};', 'const CAPS={bills:30,paid:500};', 'const syncTimers={};', 'function stampItem(b){', ' if(!b.id){const s=(b.label||"")+"|"+(b.paidAt||b.savedAt||"")+"|"+(b.total||0);let h=5381;for(let i=0;i>>0).toString(36);}', ' b.id=String(b.id).replace(/[^A-Za-z0-9_-]/g,"");', ' if(!b.updatedAt)b.updatedAt=b.paidAt||b.savedAt||new Date(0).toISOString();', ' return b;', '}', 'function newId(){return Date.now().toString(36)+Math.random().toString(36).slice(2,8);}', 'function sortKey(x){return x.paidAt||x.savedAt||x.updatedAt||"";}', 'function mergeLists(a,b,cap){', ' const map={};', ' a.concat(b).forEach(x=>{stampItem(x);const o=map[x.id];if(!o||x.updatedAt>o.updatedAt)map[x.id]=x;});', ' const all=Object.values(map).sort((p,q)=>sortKey(q).localeCompare(sortKey(p)));', ' const cutoff=Date.now()-30*24*60*60*1000;', ' const live=all.filter(x=>!x.deleted).slice(0,cap);', ' const dead=all.filter(x=>x.deleted&&new Date(x.updatedAt).getTime()>cutoff);', ' return live.concat(dead).sort((p,q)=>sortKey(q).localeCompare(sortKey(p)));', '}', 'function readList(kind){try{return JSON.parse(localStorage.getItem(KEYS[kind])||"[]").map(stampItem);}catch(e){return[];}}', 'function writeList(kind,list){localStorage.setItem(KEYS[kind],JSON.stringify(list));}', 'function liveList(kind){return readList(kind).filter(x=>!x.deleted);}', 'function commitList(kind,list){writeList(kind,mergeLists(list,[],CAPS[kind]));scheduleSync(kind);}', 'function tomb(b){return{id:b.id,deleted:true,updatedAt:new Date().toISOString(),paidAt:b.paidAt,savedAt:b.savedAt};}', 'function setBadge(cls,txt){const b=document.getElementById("syncBadge");if(!b)return;b.className="sync-badge"+(cls?" "+cls:"");b.textContent=txt||"";}', // Debounced: a burst of edits becomes one server write (one git commit), not one per click 'function scheduleSync(kind){', ' clearTimeout(syncTimers[kind]);', ' syncTimers[kind]=setTimeout(()=>syncKind(kind),2500);', '}', 'async function syncKind(kind){', ' clearTimeout(syncTimers[kind]);', ' setBadge("busy",l==="EN"?"Syncing…":"กำลังซิงก์…");', ' try{', ' const r=await fetch("/api/sync",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({shopSlug:SLUG,kind,items:readList(kind)})});', ' if(!r.ok)throw new Error("HTTP "+r.status);', ' const j=await r.json();', ' writeList(kind,mergeLists(readList(kind),j.items||[],CAPS[kind]));', ' setBadge("busy",l==="EN"?"Synced":"ซิงก์แล้ว");setTimeout(()=>setBadge("",""),1500);', ' if(kind==="bills")renderBills();else renderPaidBills();', ' return true;', ' }catch(e){', ' setBadge("err",l==="EN"?"Sync failed - will retry":"ซิงก์ล้มเหลว จะลองใหม่");', ' syncTimers[kind]=setTimeout(()=>syncKind(kind),30000);', ' return false;', ' }', '}', // Pull the fresh server copy (GitHub API, not the static Pages copy), merge it with the local // one, and push back anything the server does not have yet (e.g. bills made while offline). 'async function pullKind(kind){', ' try{', ' const r=await fetch("/api/data?shop="+encodeURIComponent(SLUG)+"&kind="+kind,{cache:"no-store"});', ' if(!r.ok)return false;', ' const server=await r.json();if(!Array.isArray(server))return false;', ' const merged=mergeLists(readList(kind),server,CAPS[kind]);', ' writeList(kind,merged);', ' const known={};server.forEach(x=>{stampItem(x);known[x.id]=x.updatedAt;});', ' if(merged.some(x=>known[x.id]!==x.updatedAt))scheduleSync(kind);', ' return true;', ' }catch(e){return false;}', '}', 'window.addEventListener("online",()=>{scheduleSync("bills");scheduleSync("paid");});', 'function saveDraft(){', ' const rows=[];', ' document.querySelectorAll(".item-row").forEach(row=>{', ' const sel=row.querySelector("select");', ' const qty=parseInt(row.querySelector(".q").value)||0;', ' if(sel.value&&qty>0)rows.push({id:sel.value,qty});', ' });', ' localStorage.setItem("pos_draft_"+SLUG,JSON.stringify({', ' rows,', ' disc:document.getElementById("disc").value,', ' sc:document.getElementById("sc").value,', ' pm:document.getElementById("pm").value,', ' tab:document.getElementById("tab").value,', ' note:document.getElementById("note").value', ' }));', '}', 'function restoreDraft(){', ' try{', ' const raw=localStorage.getItem("pos_draft_"+SLUG);', ' if(!raw)return false;', ' const d=JSON.parse(raw);', ' if(!d.rows||!d.rows.length)return false;', ' document.getElementById("disc").value=d.disc||"0";', ' document.getElementById("sc").value=d.sc||"0";', ' if(d.pm)document.getElementById("pm").value=d.pm;', ' document.getElementById("tab").value=d.tab||"Table 01";', ' document.getElementById("note").value=d.note||"";', ' document.getElementById("lines").innerHTML="";', ' const prevF=f;f="ALL";', ' const added=[];', ' d.rows.forEach(r=>{', ' const row=addNoUpd(); added.push({row,r});', ' });', ' up(true);', ' added.forEach(({row,r})=>applyRow(row,r));', ' f=prevF;', ' if(prevF!=="ALL")fb();', ' calc();return true;', ' }catch(e){return false;}', '}', // Named bill save / recall 'function collectOrder(){', ' const rows=[];', ' document.querySelectorAll(".item-row").forEach(row=>{', ' const sel=row.querySelector("select");', ' const qty=parseInt(row.querySelector(".q").value)||0;', ' if(sel.value&&qty>0){', ' const item=dataset.find(x=>x.id===sel.value);', ' rows.push({id:sel.value,qty,', ' nameEN:item?item.nameEN:"",nameTH:item?item.nameTH:"",', ' price:parseFloat(sel.dataset.price)||0});', ' }', ' });', ' return{rows,', ' disc:document.getElementById("disc").value,', ' sc:document.getElementById("sc").value,', ' pm:document.getElementById("pm").value,', ' tab:document.getElementById("tab").value,', ' note:document.getElementById("note").value,', ' total:parseFloat(document.getElementById("totalBox").textContent.replace(/[^0-9.]/g,""))||0};', '}', 'function saveBill(){', ' const order=collectOrder();', ' if(!order.rows.length){alert(l==="EN"?"No items to save.":"ยังไม่มีรายการ");return;}', ' const label=document.getElementById("billLabel").value.trim()||', ' ("Bill "+new Date().toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit"}));', ' const now=new Date().toISOString();', ' const bills=readList("bills");', ' bills.unshift({id:newId(),label,...order,savedAt:now,updatedAt:now});', ' commitList("bills",bills);', ' clearOrder();', ' renderBills();', '}', 'function recallBill(idx){', ' const b=liveList("bills")[idx];if(!b)return;', ' document.getElementById("disc").value=b.disc||"0";', ' document.getElementById("sc").value=b.sc||"0";', ' if(b.pm)document.getElementById("pm").value=b.pm;', ' document.getElementById("tab").value=b.tab||"";', ' document.getElementById("note").value=b.note||"";', ' document.getElementById("lines").innerHTML="";', ' const prevF=f;f="ALL";', ' const added=[];', ' b.rows.forEach(r=>{', ' const row=addNoUpd(); added.push({row,r});', ' });', ' up(true);', ' added.forEach(({row,r})=>applyRow(row,r));', ' f=prevF;', ' if(prevF!=="ALL")fb();', ' calc();m("calc");', '}', 'function deleteBill(idx){', ' const b=liveList("bills")[idx];if(!b)return;', ' commitList("bills",readList("bills").map(x=>x.id===b.id?tomb(x):x));', ' renderBills();', '}', 'function renderBills(){', ' const el=document.getElementById("savedBills");', ' const bills=liveList("bills");', ' if(!bills.length){el.innerHTML=`

${l==="EN"?"No saved bills yet.":"ยังไม่มีบิลที่บันทึกไว้"}

`;return;}', ' el.innerHTML=bills.map((b,i)=>`', '
', '
', ' ${esc(b.label)}', ' ${new Date(b.savedAt).toLocaleString("en-GB",{day:"2-digit",month:"short",hour:"2-digit",minute:"2-digit"})}', '
', ' ', ' ', '
`).join("");', '}', 'function clearOrder(){', ' document.getElementById("lines").innerHTML="";', ' document.getElementById("disc").value="0";', ' document.getElementById("sc").value="0";', ' document.getElementById("billLabel").value="";', ' add();calc();', ' localStorage.removeItem("pos_draft_"+SLUG);', '}', 'function markAsPaid(pmOverride){', ' const order=collectOrder();', ' if(!order.rows.length){alert(l==="EN"?"No items on this bill.":"ยังไม่มีรายการ");return;}', ' if(pmOverride)order.pm=pmOverride;', ' const label=document.getElementById("billLabel").value.trim()||', ' ("Bill "+new Date().toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit"}));', ' const now=new Date().toISOString();', ' const paid=readList("paid");', ' paid.unshift({id:newId(),label,...order,paidAt:now,updatedAt:now});', ' commitList("paid",paid);', ' clearOrder();', ' m("hist");', '}', 'function getPaidBills(unfiltered){', ' const all=liveList("paid");', ' if(unfiltered)return all;', ' const cutoff=Date.now()-30*24*60*60*1000;', ' return all.filter(b=>new Date(b.paidAt).getTime()>cutoff);', '}', 'function deletePaidBill(id){', ' if(!confirm(uiDict[l].histDelConfirm))return;', ' commitList("paid",readList("paid").map(x=>x.id===id?tomb(x):x));', ' renderPaidBills();', '}', 'function filterPaidBills(bills,mode){', ' const now=new Date();', ' if(mode==="today")return bills.filter(b=>new Date(b.paidAt).toDateString()===now.toDateString());', ' if(mode==="week"){', ' const offset=(now.getDay()+6)%7;', ' const start=new Date(now);start.setHours(0,0,0,0);start.setDate(now.getDate()-offset);', ' return bills.filter(b=>new Date(b.paidAt).getTime()>=start.getTime());', ' }', ' if(mode==="month")return bills.filter(b=>{const d=new Date(b.paidAt);return d.getFullYear()===now.getFullYear()&&d.getMonth()===now.getMonth();});', ' return bills;', '}', 'function renderPaidBills(){', ' const el=document.getElementById("paidBills");', ' const sumEl=document.getElementById("histSummary");', ' const selEl=document.getElementById("histFilter");', ' const mode=selEl?selEl.value:"all";', ' const ui=uiDict[l];', ' const bills=filterPaidBills(getPaidBills(),mode);', ' if(!bills.length){', ' sumEl.style.display="none";', ' el.innerHTML=`

${ui.histNone}

`;', ' return;', ' }', ' const grandTotal=bills.reduce((s,b)=>s+(b.total||0),0);', ' sumEl.style.display="flex";', ' sumEl.innerHTML=`${ui.histTotal} (${bills.length} ${ui.histBills})${CURRENCY}${Math.round(grandTotal)}.00`;', ' // Group by month, then by day within each month, tracking subtotals at both levels', ' const monthGroups={};', ' bills.forEach(b=>{', ' const d=new Date(b.paidAt);', ' const mKey=d.toLocaleDateString("en-GB",{month:"long",year:"numeric"});', ' const dKey=d.toLocaleDateString("en-GB",{weekday:"short",day:"2-digit",month:"short",year:"numeric"});', ' if(!monthGroups[mKey])monthGroups[mKey]={total:0,days:{}};', ' monthGroups[mKey].total+=(b.total||0);', ' if(!monthGroups[mKey].days[dKey])monthGroups[mKey].days[dKey]=[];', ' monthGroups[mKey].days[dKey].push(b);', ' });', ' el.innerHTML=Object.entries(monthGroups).map(([month,mg])=>{', ' const dayHtml=Object.entries(mg.days).map(([day,dayBills])=>{', ' const dayTotal=dayBills.reduce((s,b)=>s+(b.total||0),0);', ' const cards=dayBills.map(b=>`', ' `).join("");', ' return `
${day}${CURRENCY}${Math.round(dayTotal)}.00
${cards}`;', ' }).join("");', ' return `
${month}${CURRENCY}${Math.round(mg.total)}.00
${dayHtml}`;', ' }).join("");', '}', // Open a single paid bill preview in a popup with print button 'function viewPaidBill(id){', ' const b=getPaidBills(true).find(x=>x.id===id);if(!b)return;', ' const labels=dict[l];', ' const dStr=new Date(b.paidAt).toLocaleString("en-GB");', ' let rows="";', ' b.rows.forEach(r=>{rows+=`${esc(l==="EN"?r.nameEN:r.nameTH)}${r.qty}${Math.round(r.price)}.00${Math.round(r.qty*r.price)}.00`;});', ' const win=window.open("","_blank");if(!win)return;', ' win.document.write(`${esc(b.label)}', '

${labels.title}

', '
${dStr} · ${esc(b.tab)} · ${esc(b.pm)}${b.note?" · "+esc(b.note):""}
', ' ${rows}
ItemQtyUnitTotal
', '
${labels.total} ${CURRENCY}${Math.round(b.total||0)}.00
', '
', ' `);', ' win.document.close();', '}', 'function downloadBlob(content,filename,mime){', ' const blob=new Blob([content],{type:mime});', ' const url=URL.createObjectURL(blob);', ' const a=document.createElement("a");a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);', '}', 'function getHistorySelected(){', ' const mode=document.getElementById("histFilter")?document.getElementById("histFilter").value:"all";', ' return filterPaidBills(getPaidBills(),mode);', '}', 'function exportFileName(ext){', ' const now=new Date();', ' const stamp=now.getFullYear()+"-"+String(now.getMonth()+1).padStart(2,"0")+"-"+String(now.getDate()).padStart(2,"0");', ' return `${SLUG}-bills-${stamp}.${ext}`;', '}', 'function downloadHistoryJson(){', ' const bills=getHistorySelected();', ' if(!bills.length){alert(l==="EN"?"No bills to export.":"ไม่มีบิลจะส่งออก");return;}', ' downloadBlob(JSON.stringify({slug:SLUG,exportedAt:new Date().toISOString(),bills},null,2),exportFileName("json"),"application/json");', '}', 'function downloadHistoryCsv(){', ' const bills=getHistorySelected();', ' if(!bills.length){alert(l==="EN"?"No bills to export.":"ไม่มีบิลจะส่งออก");return;}', ' const rows=[["billId","label","paidAt","payment","table","note","total","nameEN","qty","price"]];', ' const q=String.fromCharCode(34);', ' const esc=v=>q+String(v||"").split(q).join(q+q)+q;', ' bills.forEach(b=>{', ' b.rows.forEach((r,i)=>{', ' if(i===0)rows.push([esc(b.id),esc(b.label),esc(b.paidAt),esc(b.pm),esc(b.tab),esc(b.note),esc(b.total),esc(r.nameEN),esc(r.qty),esc(r.price)]);', ' else rows.push(["","","","","","","",esc(r.nameEN),esc(r.qty),esc(r.price)]);', ' });', ' if(!b.rows.length)rows.push([esc(b.id),esc(b.label),esc(b.paidAt),esc(b.pm),esc(b.tab),esc(b.note),esc(b.total),"","",""]);', ' });', ' const csv=rows.map(r=>r.join(",")).join("\\n");', ' downloadBlob(csv,exportFileName("csv"),"text/csv");', '}', 'function printHistorySummary(){', ' const bills=getHistorySelected();', ' if(!bills.length){alert(l==="EN"?"No bills to print.":"ไม่มีบิลจะพิมพ์");return;}', ' const labels=dict[l];const ui=uiDict[l];', ' const grandTotal=bills.reduce((s,b)=>s+(b.total||0),0);', ' let rows="";', ' bills.forEach(b=>{', ' rows+=`${esc(b.label)}
${new Date(b.paidAt).toLocaleString("en-GB")}${esc(b.pm)}${CURRENCY}${Math.round(b.total||0)}.00`;', ' });', ' const win=window.open("","_blank");if(!win)return;', ' win.document.write(`${ui.histTotal}', '

${labels.title}

', '
${SLUG} · ${new Date().toLocaleString("en-GB")}
', ' ${rows}
${ui.histTotal}${ui.pm}${ui.histTotal}
', '
${CURRENCY}${Math.round(grandTotal)}.00 · ${bills.length} ${ui.histBills}
', ' `);', ' win.document.close();', ' setTimeout(()=>win.print(),400);', '}', // Init — restore draft after live menu loads so item IDs resolve correctly 'fb();', 'loadLiveMenu().then(()=>{ Promise.all([pullKind("bills"),pullKind("paid")]).then(()=>{ if(!restoreDraft())add(); renderBills(); renderPaidBills(); }); });', 'window.addEventListener("beforeinstallprompt",e=>{e.preventDefault();window.__installEvt=e;const b=document.getElementById("installBtn");if(b){b.style.display="inline-block";b.onclick=()=>{e.prompt();e.userChoice.then(()=>{b.style.display="none";window.__installEvt=null;});};};});' ].join('\n'); // " "<\/" so no user text (menu names, shop name) can close the script tag early return html + '\n