Permission is required.
+You are not authorized to view this page.
+ +diff --git a/access-denied.html b/access-denied.html index ec666f2..9a9d7a4 100644 --- a/access-denied.html +++ b/access-denied.html @@ -1,80 +1,25 @@ -
- - -You are not authorized to view this page.
+ +${esc(t(message))}
${esc(t("Set up UPI in Branch details. UPI QR payments require INR."))}
`) : ""} ${method === "Cash" ? `${esc(t("Confirm only after receiving the money. This does not charge a card or bank account."))}
`; if (plan.dueMinor === 0) body += `${esc(t("Payment recorded"))}
`; } else body = `${esc(t(busy ? "Loading..." : "Connect to the shop server before collecting payment. You can still take orders offline."))}
`; - dialog.innerHTML = `${esc(t(error))}
` : ""}${esc(t("Confirm only after receiving the money. This does not charge a card or bank account."))}
${esc(at.toLocaleString())}
` : ''}${receipt.staff ? `${esc(receipt.staff)}
` : ''}${esc(t(error))}
` : ""}${busy ? "Loading..." : "Could not load the bill."}
`; else if (stage === "setup") - body = `${snapshot.lines.some(line => line.seat > 0) ? '' : ''}Choose who pays for each item. Use Share for a shared dish.
${snapshot.lines .map((line) => { @@ -104,12 +122,14 @@ error = ""; render(); try { + checkOwner(); const response = await POSNIC.api.get( "/sales/guestBills/table?branchId=" + encodeURIComponent(state.branchId) + "&table_number=" + encodeURIComponent(state.table), ); + checkOwner(); if (response.type !== "success" || !response.data?.revision) throw new Error("Could not load the bill."); const changed = snapshot && snapshot.revision !== response.data.revision; @@ -141,13 +161,17 @@ async function send() { if (busy) return; try { + checkOwner(); guests(); } catch (e) { error = e.message; render(); return; } - if (state.pending && state.pending.base !== base()) { + if ( + state.pending && + (state.pending.owner !== owner || state.pending.base !== base()) + ) { error = "Reconnect to the server that authorized this phone. Orders are retained."; render(); @@ -155,6 +179,7 @@ } if (!state.pending) state.pending = { + owner, base: base(), body: { branchId: state.branchId, @@ -179,6 +204,7 @@ "/sales/guestBills/print", state.pending.body, ); + checkOwner(); if (response.type !== "success" || !response.data?.queued) throw new Error("Could not send the guest bills."); localStorage.removeItem(draftKey); @@ -250,14 +276,36 @@ else if (a === "back") back(); else if (a === "reload") reload(); else if (a === "by-seat") { - const seats=[...new Set(snapshot.lines.map(line=>Number(line.seat)||0).filter(Boolean))].sort((a,b)=>a-b); - if(seats.length<2||seats.length>20){error='Choose between 2 and 20 guests.';render();return;} - state.plan.mode='items'; - state.plan.guests=seats.map(seat=>t('Seat {0}').replace('{0}',seat)); - state.plan.allocations=Object.fromEntries(snapshot.lines.map(line=>[line.id,seats.map(seat=>!line.seat||seat===Number(line.seat)?1:0)])); - state.pending=null;stage='items';error='';persist();render(); - } - else if (a === "more" || a === "less") + const seats = [ + ...new Set( + snapshot.lines + .map((line) => Number(line.seat) || 0) + .filter(Boolean), + ), + ].sort((a, b) => a - b); + if (seats.length < 2 || seats.length > 20) { + error = "Choose between 2 and 20 guests."; + render(); + return; + } + state.plan.mode = "items"; + state.plan.guests = seats.map((seat) => + t("Seat {0}").replace("{0}", seat), + ); + state.plan.allocations = Object.fromEntries( + snapshot.lines.map((line) => [ + line.id, + seats.map((seat) => + !line.seat || seat === Number(line.seat) ? 1 : 0, + ), + ]), + ); + state.pending = null; + stage = "items"; + error = ""; + persist(); + render(); + } else if (a === "more" || a === "less") resetCount(state.plan.guests.length + (a === "more" ? 1 : -1)); else if (a === "share-all") { state.plan.allocations[b.dataset.lineId] = state.plan.guests.map( @@ -319,6 +367,8 @@ } else render(); }); } + owner = identity(); + collectEnabled = false; state = { branchId: localStorage.getItem("branch_id"), table, @@ -328,11 +378,14 @@ snapshot = null; stage = "setup"; error = ""; - draftKey = - "posnic.guest-bills:" + base() + ":" + state.branchId + ":" + table; + draftKey = "posnic.guest-bills:v2:" + owner + ":" + table; try { const saved = JSON.parse(localStorage.getItem(draftKey) || "null"); - if (saved?.state && saved.snapshot) { + if ( + saved?.owner === owner && + saved?.state?.table === table && + saved.snapshot + ) { state = saved.state; snapshot = saved.snapshot; stage = saved.stage || "setup"; @@ -346,7 +399,16 @@ stage = "review"; render(); } else { - collectEnabled = (await window.CaptainPayments?.available()) || false; + busy = true; + render(); + try { + collectEnabled = (await window.CaptainPayments?.available()) || false; + checkOwner(); + } catch { + busy = false; + return; + } + busy = false; await reload(); } } @@ -354,11 +416,17 @@ const button = e.target.closest("[data-split-table]"); if (button) open(button.dataset.splitTable); }); - window.addEventListener("captain:back", (event) => { - if (dialog?.open) { - event.preventDefault(); - back(); - } - }); + window.addEventListener( + "captain:back", + (event) => { + if (dialog?.open) { + if (document.querySelector("#captain-payments[open]")) return; + event.preventDefault(); + event.stopImmediatePropagation(); + back(); + } + }, + true, + ); window.GuestBills = { open }; })(); diff --git a/assets/common/guest-update.js b/assets/common/guest-update.js new file mode 100644 index 0000000..bc561a6 --- /dev/null +++ b/assets/common/guest-update.js @@ -0,0 +1,61 @@ +/* Cover updates retain their original shop, staff, branch and issuer on retry. */ +(function(root){ + const active=new Map(); + const t=text=>root.I18N?.t(text)||text; + const issuer=()=>root.POSNIC?.session?.base||root.POSNIC?.server?.baseUrl||''; + function owner(){ + const session=root.POSNIC?.session, branch=localStorage.getItem('branch_id'); + if(!session?.shopKey||!session.user?.id||!branch)throw new Error(t('Sign in with your account')); + return JSON.stringify([session.shopKey,session.user.id,branch]); + } + const key=(identity,order)=>'posnic.guest-update:'+identity+':'+order; + const invalid=()=>new Error(t('Could not save. Please try again.')); + function pending(order){ + const identity=owner(),entry=JSON.parse(localStorage.getItem(key(identity,order))||'null'); + if(entry&&(entry.owner!==identity||entry.body?.orderId!==order||typeof entry.issuer!=='string'|| + typeof entry.body.request_id!=='string'||!Number.isInteger(entry.body.guests)))throw invalid(); + return entry; + } + function save(order,guests){ + const existing=pending(order); + if(existing)return existing; + if(!Number.isInteger(guests)||guests<1||guests>1000)throw invalid(); + const entry={owner:owner(),issuer:issuer(),body:{orderId:order,guests,request_id:crypto.randomUUID()}}; + localStorage.setItem(key(entry.owner,order),JSON.stringify(entry)); + return entry; + } + async function run(entry){ + const storageKey=key(entry.owner,entry.body.orderId); + if(active.has(storageKey))return active.get(storageKey); + const same=()=>{ + if(owner()!==entry.owner)throw new Error(t('Sign in with your account')); + if(issuer()!==entry.issuer)throw new Error(t('Reconnect to the server that authorized this phone. Orders are retained.')); + }; + const matches=result=>result?.request_id===entry.body.request_id&&result.orderId===entry.body.orderId&&result.guests===entry.body.guests; + const operation=(async()=>{ + same(); + let result; + try{result=await root.POSNIC.api.post('/captain/v1/tables/guests',entry.body);} + catch(error){ + same(); + if(error.status===409){ + try{ + const status=await root.POSNIC.api.post('/captain/v1/tables/guests/status',{request_id:entry.body.request_id}); + same(); + if(matches(status)&&status.state==='cancelled')localStorage.removeItem(storageKey); + }catch{/* An uncertain status must retain the original request. */} + } + throw error; + } + same(); + if(!matches(result)||result.state!=='completed')throw invalid(); + localStorage.removeItem(storageKey); + return result; + })(); + active.set(storageKey,operation); + try{return await operation;}finally{active.delete(storageKey);} + } + root.CaptainGuestUpdate={pending,save:(order,guests)=>run(save(order,guests)),resume:order=>{ + const entry=pending(order);if(!entry)throw invalid();return run(entry); + }}; +})(globalThis); diff --git a/assets/common/item-transfer.js b/assets/common/item-transfer.js new file mode 100644 index 0000000..e24711f --- /dev/null +++ b/assets/common/item-transfer.js @@ -0,0 +1,105 @@ +/* Transfers retain the original issuer, staff and request through uncertain responses. */ +(function(root){ + 'use strict'; + const active=new Map(); + const t=text=>root.I18N?.t(text)||text; + const invalid=()=>new Error(t('Could not save. Please try again.')); + const issuer=()=>root.POSNIC?.session?.base||root.POSNIC?.server?.baseUrl||''; + function owner(){ + const session=root.POSNIC?.session,branch=localStorage.getItem('branch_id'); + if(!session?.shopKey||!session.user?.id||!branch)throw new Error(t('Sign in with your account')); + return JSON.stringify([session.shopKey,session.user.id,branch]); + } + const key=(identity,order)=>'posnic.item-transfer:'+identity+':'+order; + const oid=value=>typeof value==='string'&&/^[a-f0-9]{24}$/i.test(value); + function valid(body){ + const d=body?.destination; + return oid(body?.orderId)&&typeof body.requestId==='string'&&/^[a-zA-Z0-9_-]{16,80}$/.test(body.requestId)&& + typeof body.revision==='string'&&/^[a-f0-9]{64}$/.test(body.revision)&& + Array.isArray(body.items)&&body.items.length>0&&body.items.length<=200&& + body.items.every(row=>row&&typeof row.id==='string'&&row.id.length>0&&row.id.length<=160&& + Number.isFinite(row.quantity)&&row.quantity>0&& + (row.servedQuantity===undefined||(Number.isFinite(row.servedQuantity)&&row.servedQuantity>=0&&row.servedQuantity<=row.quantity)))&& + new Set(body.items.map(row=>row.id)).size===body.items.length&& + Array.isArray(d?.tableIds)&&d.tableIds.length>0&&d.tableIds.length<=20&&d.tableIds.every(oid)&& + new Set(d.tableIds).size===d.tableIds.length&&d.tableIds.includes(d.primaryId)&& + Number.isInteger(d.guests)&&d.guests>0&&d.guests<=1000; + } + // The screen selects kitchen round identities, never catalogue IDs. Two + // preparations of the same dish must remain separate, including their notes. + function selection(rounds,choices){ + if(!Array.isArray(rounds)||!Array.isArray(choices)||!choices.length||choices.length>200)throw invalid(); + const lines=new Map(),seen=new Set(); + const units=value=>{ + if(typeof value!=='number'||!Number.isFinite(value)||value<0||value>1000000)throw invalid(); + const n=Math.round(value*1000);if(Math.abs(n-value*1000)>0.000001)throw invalid();return n; + }; + for(const round of rounds)for(const line of round.items||[]){ + if(!line?.id||lines.has(line.id))throw invalid();lines.set(line.id,line); + } + return choices.map(choice=>{ + const line=lines.get(choice?.id); + if(!line||seen.has(choice.id))throw invalid();seen.add(choice.id); + const quantity=units(choice.quantity),available=units(line.quantity),served=units(line.served||0); + if(!quantity||quantity>available||served>available)throw invalid(); + let moved=choice.servedQuantity; + if(moved===undefined){ + if(quantity===available)moved=served/1000; + else if(!served)moved=0; + else if(served===available)moved=quantity/1000; + else throw Object.assign(new Error(t('Served')),{code:'SERVED_QUANTITY_REQUIRED',lineId:choice.id}); + } + const count=units(moved); + if(count>quantity||count>served||quantity-count>available-served)throw invalid(); + return {id:choice.id,quantity:quantity/1000,servedQuantity:count/1000}; + }); + } + function pending(order){ + const identity=owner(),entry=JSON.parse(localStorage.getItem(key(identity,order))||'null'); + if(entry&&(entry.owner!==identity||entry.body?.orderId!==order||!entry.issuer||!valid(entry.body)))throw invalid(); + return entry; + } + function remember(order,input){ + const existing=pending(order);if(existing)return existing; + const body=JSON.parse(JSON.stringify({orderId:order,requestId:crypto.randomUUID(), + revision:input.revision,items:input.items,destination:input.destination})); + if(!valid(body)||!issuer())throw invalid(); + const entry={owner:owner(),issuer:issuer(),body}; + localStorage.setItem(key(entry.owner,order),JSON.stringify(entry)); + return entry; + } + async function run(entry){ + const storageKey=key(entry.owner,entry.body.orderId); + if(active.has(storageKey))return active.get(storageKey); + const same=()=>{ + if(owner()!==entry.owner)throw new Error(t('Sign in with your account')); + if(issuer()!==entry.issuer)throw new Error(t('Reconnect to the server that authorized this phone. Orders are retained.')); + }; + const matches=result=>result?.requestId===entry.body.requestId&&result.sourceId===entry.body.orderId; + function accepted(result){ + same(); + if(!matches(result)||result.state!=='completed'||!oid(result.destinationId)||typeof result.sourceClosed!=='boolean')throw invalid(); + localStorage.removeItem(storageKey);return result; + } + const operation=(async()=>{ + same(); + try{return accepted(await root.POSNIC.api.post('/captain/v1/tables/transfer/complete',entry.body));} + catch(error){ + same(); + try{ + const result=await root.POSNIC.api.post('/captain/v1/tables/transfer/status',{ + orderId:entry.body.orderId,requestId:entry.body.requestId}); + same(); + if(matches(result)&&result.state==='completed')return accepted(result); + if(matches(result)&&result.state==='cancelled')localStorage.removeItem(storageKey); + }catch{/* Uncertain recovery retains the original request. */} + throw error; + } + })(); + active.set(storageKey,operation); + try{return await operation;}finally{active.delete(storageKey);} + } + root.CaptainItemTransfer={selection,pending,complete:(order,input)=>run(remember(order,input)),resume:order=>{ + const entry=pending(order);if(!entry)throw invalid();return run(entry); + }}; +})(globalThis); diff --git a/assets/common/kitchen-message.css b/assets/common/kitchen-message.css index 5110f89..94b58b7 100644 --- a/assets/common/kitchen-message.css +++ b/assets/common/kitchen-message.css @@ -1 +1,205 @@ -.kitchen-message{max-width:640px;margin-inline:auto;padding-bottom:calc(32px + env(safe-area-inset-bottom));}.voice-actions{display:grid;gap:12px;margin-block:24px}.voice-actions button{min-height:48px}.kitchen-message audio{width:100%;margin-block:20px}.kitchen-message [hidden]{display:none!important}.voice-history-item{padding-block:16px;border-bottom:1px solid var(--border,#ccd5dd)}#voice-error{color:var(--danger,#b42318)}.kitchen-message p{overflow-wrap:anywhere} +.kitchen-message { + box-sizing: border-box; + max-width: 960px; + margin-inline: auto; + padding: 24px 16px calc(32px + env(safe-area-inset-bottom)); + display: grid; + gap: 24px; + align-items: start; +} +.kitchen-message * { + box-sizing: border-box; +} +.voice-compose, +.voice-messages { + min-width: 0; + padding: 20px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); +} +.kitchen-message h2 { + font-size: 18px; + line-height: 1.4; + margin: 0 0 12px; + font-weight: 650; +} +.kitchen-message p { + overflow-wrap: anywhere; + line-height: 1.6; + color: var(--ink-soft); + font-size: 14px; +} +.voice-actions { + display: grid; + gap: 12px; + margin-block: 24px; +} +.voice-actions button { + min-height: 48px; + font: inherit; + border-radius: 12px; +} +.voice-actions .ui-btn { + background: var(--accent); + color: var(--accent-ink, #fff); + border-color: var(--accent); +} +.kitchen-message .ui-btn-quiet { + background: var(--surface); + color: var(--ink); + border: 1px solid var(--line); + min-height: 44px; + padding: 8px 14px; + border-radius: 12px; +} +.kitchen-message button:disabled { + opacity: 0.55; + cursor: wait; +} +.kitchen-message audio { + width: 100%; + margin-block: 20px; +} +.kitchen-message [hidden] { + display: none !important; +} +.voice-history-item { + padding-block: 16px; + border-bottom: 1px solid var(--line); +} +.voice-history-item:last-child { + border-bottom: 0; +} +.voice-history-item strong { + font-size: 14px; +} +.voice-history-item p { + margin-block: 8px 0; +} +.voice-messages header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.voice-messages header h2 { + margin: 0; +} +#voice-error { + color: var(--bad); +} +#voice-error:empty { + display: none; +} +#voice-status { + min-height: 48px; + background: var(--surface-sunk); + padding: 12px; + border-radius: 12px; + color: var(--ink); +} +.me-back { + display: grid; + place-items: center; + text-decoration: none; +} +@media (min-width: 768px) { + .kitchen-message { + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + padding-top: 32px; + } +} + +.voice-composer { + border: 1px solid var(--line); + border-radius: 20px; + padding: 16px; + background: var(--surface-sunk); +} +.voice-track { + display: flex; + align-items: center; + gap: 10px; + min-height: 48px; + direction: ltr; +} +#voice-wave { + flex: 1; + min-width: 0; + width: 100%; + height: 40px; + color: var(--ink-soft); +} +#voice-duration { + font-size: 14px; + font-variant-numeric: tabular-nums; + color: var(--ink-soft); +} +.voice-composer .voice-actions { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + align-items: center; + margin: 16px 0 0; + direction: ltr; +} +.voice-composer button { + display: grid; + place-items: center; + width: 48px; + height: 48px; + min-width: 48px; + padding: 12px; + border-radius: 50%; + border: 0; + background: transparent; + color: var(--ink); + cursor: pointer; +} +.voice-composer button:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 3px; +} +.voice-composer button svg { + width: 24px; + height: 24px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +#voice-record { + grid-column: 2; + justify-self: center; + color: var(--bad, #dc2626); + background: var(--surface); +} +#voice-discard { + grid-column: 1; + color: var(--ink-soft); +} +#voice-send { + grid-column: 3; + justify-self: end; + background: var(--accent); + color: var(--accent-ink, #fff); +} +#voice-composer[data-recording="true"] #voice-wave { + color: var(--bad, #dc2626); +} +#voice-composer[data-recording="true"] #voice-duration { + color: var(--bad, #dc2626); +} +@media (max-width: 360px) { + .voice-compose { + padding: 16px; + } + .voice-composer { + padding: 12px; + } +} + +.voice-composer .voice-actions button { + border-radius: 50% !important; +} diff --git a/assets/common/kitchen-message.js b/assets/common/kitchen-message.js index 57d5515..2dc3f61 100644 --- a/assets/common/kitchen-message.js +++ b/assets/common/kitchen-message.js @@ -1,36 +1,313 @@ -(function(win){ - 'use strict'; - const t=s=>win.I18N?.t(s)||s, el=id=>document.getElementById(id); - let owner,key,draft,recorder,stream,timer,busy=false,opening=false; - const identity=()=>JSON.stringify([win.POSNIC.session.shopKey,win.POSNIC.session.user?.id,localStorage.getItem('branch_id')]); - function check(){if(identity()!==owner)throw Error(t('Sign in with your account'));} - const api=async(action,body={})=>{check();const result=await win.POSNIC.api.post('/captain/v1/kitchen-audio/'+action,body,{timeout:15000});check();return result;}; - function save(){check();localStorage.setItem(key,JSON.stringify(draft));} - function error(e){el('voice-error').textContent=['NotAllowedError','NotFoundError','NotReadableError'].includes(e?.name)?t('Microphone unavailable. Check app permissions.'):e?.message||t('Could not save. Please try again.');} - function render(){const recording=recorder?.state==='recording';el('voice-record').hidden=!!draft;el('voice-record').disabled=busy||opening;el('voice-record').textContent=t(recording?'Stop recording':'Record voice note');el('voice-send').hidden=!draft||!!draft.sent;el('voice-send').disabled=busy;el('voice-discard').hidden=!draft;el('voice-discard').disabled=busy;el('voice-discard').textContent=t(draft?.sent?'Close':'Discard recording');el('voice-preview').hidden=!draft?.data;el('voice-preview').src=draft?.data||'';el('voice-status').textContent=t(recording?'Recording…':draft?.complete?'Playback completed':draft?.sent?'Queued for playback':draft?'Voice note saved on this phone':'Tap to record, then review and send.');} - function stop(){clearTimeout(timer);if(recorder?.state==='recording')recorder.stop();stream?.getTracks().forEach(track=>track.stop());} - async function record(){ - if(busy||opening)return;if(recorder?.state==='recording'){stop();return;} - opening=true;render();el('voice-error').textContent=''; - try{ - check();if(!navigator.mediaDevices?.getUserMedia||!win.MediaRecorder)throw Error(t('Microphone unavailable. Check app permissions.')); - stream=await navigator.mediaDevices.getUserMedia({audio:true,video:false});check(); - if(document.hidden){stop();return;} - const parts=[];recorder=new MediaRecorder(stream,{audioBitsPerSecond:32000}); - recorder.ondataavailable=e=>{if(e.data.size)parts.push(e.data);}; - recorder.onerror=()=>{error(Error(t('Microphone unavailable. Check app permissions.')));stop();}; - recorder.onstop=async()=>{stream?.getTracks().forEach(track=>track.stop());busy=true;render();try{const blob=new Blob(parts,{type:recorder.mimeType});if(!blob.size||blob.size>1000000)throw Error(t('Recording failed. Please record again.'));const data=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.onerror=reject;reader.readAsDataURL(blob);});check();draft={data,created:Date.now()};save();}catch(e){error(e);}finally{busy=false;recorder=null;render();}}; - recorder.start();timer=setTimeout(stop,30000); - }catch(e){stop();error(e);}finally{opening=false;render();} - } - async function send(){if(busy||!draft||draft.sent)return;busy=true;el('voice-error').textContent='';render();try{ - check();save(); - // Persist the session before uploading so an ambiguous response retries the same message. - if(!draft.id){const session=await api('start');if(!session?.id)throw Error(t('Could not save. Please try again.'));draft.id=session.id;save();} - const result=await api('voice',{id:draft.id,data:draft.data});if(result?.id!==draft.id||!result.queued)throw Error(t('Could not save. Please try again.')); - draft.sent=true;save();await refresh(); - }catch(e){error(e);}finally{busy=false;render();}} - async function refresh(){try{const result=await api('status');if(draft?.sent && result.jobs?.some(job=>job.id===draft.id && job.complete)){draft.complete=true;save();render();}const host=el('voice-history');host.replaceChildren();for(const job of result.jobs||[]){const row=document.createElement('div');row.className='voice-history-item';const title=document.createElement('strong');title.textContent=t(job.complete?'Playback completed':'Queued for playback');row.append(title);for(const target of job.targets||[]){const p=document.createElement('p');p.textContent=target.label+' · '+t(target.status);row.append(p);}host.append(row);}}catch(e){error(e);}} - async function init(){try{await win.POSNIC.session.whenReady?.();if(!win.POSNIC.session.user?.id||!win.POSNIC.session.shopKey||!localStorage.getItem('branch_id'))throw Error(t('Sign in with your account'));owner=identity();key='posnic.kitchen-voice:'+owner;draft=JSON.parse(localStorage.getItem(key)||'null');render();el('voice-record').onclick=record;el('voice-send').onclick=send;el('voice-refresh').onclick=refresh;el('voice-discard').onclick=()=>{if(busy)return;check();localStorage.removeItem(key);draft=null;render();};await refresh();}catch(e){error(e);el('voice-record').disabled=true;}} - document.addEventListener('visibilitychange',()=>{if(document.hidden)stop();});win.addEventListener('pagehide',stop);init(); +(function (win) { + "use strict"; + const t = (s) => win.I18N?.t(s) || s, + el = (id) => document.getElementById(id); + let owner, + key, + draft, + recorder, + busy = false, + refreshing = null, + leaving = false, + sending = false; + const identity = () => + JSON.stringify([ + win.POSNIC.session.shopKey, + win.POSNIC.session.user?.id, + localStorage.getItem("branch_id"), + ]); + function check() { + if (identity() !== owner) throw Error(t("Sign in with your account")); + } + const api = async (action, body = {}) => { + check(); + const result = await win.POSNIC.api.post( + "/captain/v1/kitchen-audio/" + action, + body, + { timeout: 15000 }, + ); + check(); + return result; + }; + function save(value = draft) { + check(); + localStorage.setItem(key, JSON.stringify(value)); + } + function error(e) { + el("voice-error").textContent = [ + "NotAllowedError", + "NotFoundError", + "NotReadableError", + ].includes(e?.name) + ? t("Microphone unavailable. Check app permissions.") + : e?.message || t("Could not save. Please try again."); + } + const icons = { + mic: 'Enter your PIN
' + '' + - '' + + '' + '' + '' + '' +
numberMark +
escape(globalThis.ItemLanguage ? globalThis.ItemLanguage.name(product) : product.name) +
diff --git a/assets/common/mobile-gestures.js b/assets/common/mobile-gestures.js
index 3182ec9..edbe341 100644
--- a/assets/common/mobile-gestures.js
+++ b/assets/common/mobile-gestures.js
@@ -100,7 +100,7 @@
controls();
return pending;
}
- window.MobileGestures = { refresh };
+ window.MobileGestures = { refresh, setRefresh(action) { refreshAction = action; document.documentElement.classList.toggle('mobile-refresh-enabled', typeof action === 'function'); } };
document.addEventListener('DOMContentLoaded', () => {
const path = location.pathname;
if (path.endsWith('kot-management.html')) refreshAction = () => loadTables();
@@ -147,7 +147,7 @@
document.addEventListener('touchstart', event => {
suppressClickUntil = 0; // A fresh tap is intentional, not the prior swipe's click.
reset();
- if (event.touches.length === 1 && !event.target.closest('button')) {
+ if (event.touches?.length === 1 && !event.target.closest('button')) {
const menuHead = event.target.closest('#menu-index .ui-sheet-head, #menu-index .mobile-sheet-handle');
const summaryHead = event.target.closest('#cart-summary-sheet .cart-sheet-handle, #cart-summary-sheet .cart-sheet-header');
const sheet = menuHead ? menuHead.closest('.ui-sheet') : summaryHead?.closest('.cart-sheet');
@@ -159,7 +159,7 @@
}
}
const adapter = details();
- if (adapter && !adapter.busy() && !navigating && !pending && !editing() && event.touches.length === 1 &&
+ if (adapter && !adapter.busy() && !navigating && !pending && !editing() && event.touches?.length === 1 &&
adapter.root().contains(event.target) && !horizontalScroller(event.target,adapter.root()) && window.getSelection()?.isCollapsed !== false && !event.target.closest('button, a, input, textarea, select, [contenteditable="true"]')) {
const touch = event.touches[0];
if (touch.clientX < 24 || touch.clientX > innerWidth - 24) return;
@@ -167,8 +167,8 @@
root:adapter.root(), id:adapter.current(), head:!!event.target.closest(adapter.header)};
return;
}
- if (!refreshAction || pending || event.touches.length !== 1 || overlayOpen() || editing() ||
- event.target.closest('button, input, textarea, select, [contenteditable="true"]') || !atTop(event.target)) return;
+ if (!refreshAction || pending || event.touches?.length !== 1 || overlayOpen() || editing() ||
+ event.target.closest('button:not([data-view-order]), input, textarea, select, [contenteditable="true"]') || !atTop(event.target)) return;
const touch = event.touches[0];
if (touch.clientX < 24 || touch.clientX > innerWidth - 24) return;
gesture = {x:touch.clientX, y:touch.clientY, target:event.target, distance:0};
@@ -176,7 +176,7 @@
document.addEventListener('touchmove', event => {
if (!gesture) return;
if (gesture.adapter) {
- if (event.touches.length !== 1 || details() !== gesture.adapter || gesture.adapter.busy() || editing() || gesture.adapter.current() !== gesture.id) { reset(); return; }
+ if (event.touches?.length !== 1 || details() !== gesture.adapter || gesture.adapter.busy() || editing() || gesture.adapter.current() !== gesture.id) { reset(); return; }
const touch = event.touches[0], dx = touch.clientX - gesture.x, dy = touch.clientY - gesture.y;
if (!gesture.axis && Math.max(Math.abs(dx),Math.abs(dy)) > 12) gesture.axis = Math.abs(dx) > Math.abs(dy) * 1.5 ? 'x' : 'y';
if (gesture.axis === 'x') {
@@ -190,7 +190,7 @@
}
return;
}
- if (event.touches.length !== 1 || (!gesture.sheet && (overlayOpen() || editing() || !atTop(gesture.target)))) { reset(); return; }
+ if (event.touches?.length !== 1 || (!gesture.sheet && (overlayOpen() || editing() || !atTop(gesture.target)))) { reset(); return; }
const touch = event.touches[0];
const dx = touch.clientX - gesture.x, dy = touch.clientY - gesture.y;
if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy) || dy < -8) { reset(); return; }
diff --git a/assets/common/navigation.js b/assets/common/navigation.js
new file mode 100644
index 0000000..6d29811
--- /dev/null
+++ b/assets/common/navigation.js
@@ -0,0 +1,58 @@
+(function () {
+ "use strict";
+ const routes = [
+ [
+ "kot-management.html",
+ "Tables",
+ '
Ask your manager to reconnect this phone. Your orders are saved.
'; + const close = () => {window.removeEventListener("captain:back", back, true);dialog.close();dialog.remove();$("captain-login-help").focus();}; + const back = event => {event.preventDefault();event.stopImmediatePropagation();close();}; + dialog.addEventListener("cancel", back); + window.addEventListener("captain:back", back, true); + dialog.addEventListener("click", event => { + const action = event.target.closest("[data-recovery]")?.dataset.recovery; + if (!action) return; + close(); + if (action === "password") $("password").focus(); + if (action === "account") {CaptainOnboarding.open();$("captain-cloud-login").click();} + }); + document.body.append(dialog);window.I18N?.apply(dialog);dialog.showModal(); + } document.addEventListener("DOMContentLoaded", () => { $("captain-server").value = POSNIC.server.baseUrl || ""; updateConnectAction(); + $("captain-login-help").onclick = recoveryHelp; $("captain-code-toggle").onclick = showCode; $("captain-change-shop").onclick = () => CaptainOnboarding.open(); $("captain-connect").onclick = () => { @@ -628,22 +677,22 @@ $("captain-confirm").checked = false; }); $("captain-scan").onclick = () => { - if (operation) return; + if (operation || selection) return; POSNIC_CONNECT.startScan((base) => { $("captain-server").value = base; void run((signal) => useAddress(base, signal)); }); }; const findShop = () => { - if (operation) return; + if (operation || selection) return; showView("wifi"); void run(search); }; $("captain-search").onclick = findShop; $("captain-search-again").onclick = findShop; $("connection-settings").onclick = () => { - $("connection-lan").value = POSNIC.server.lan || ""; - $("connection-cloud").value = POSNIC.server.cloud || ""; + $("connection-lan").value = POSNIC.server.lan || (POSNIC.server.isLocal ? POSNIC.server.baseUrl : "") || ""; + $("connection-cloud").value = POSNIC.server.cloud || (!POSNIC.server.isLocal ? POSNIC.server.baseUrl : "") || ""; showView("settings"); }; $("captain-pair").onclick = () => @@ -654,7 +703,7 @@ ), ); $("captain-cloud-login").onclick = () => { - if (operation) return; + if (operation || selection) return; showView("cloud"); void run((signal) => cloud("login", signal)); }; @@ -662,6 +711,7 @@ void run((signal) => cloud("signup", signal)); $("captain-cancel").onclick = () => { operation?.abort(); + cancelSelection(); POSNIC_CONNECT.stopScan(); if (view === "cloud") showView("address"); note("Connection cancelled."); @@ -669,12 +719,14 @@ $("connection-back").onclick = () => { if (view === "start") return CaptainOnboarding.close(); operation?.abort(); + cancelSelection(); POSNIC_CONNECT.stopScan(); showView("start"); }; $("captain-address-toggle").onclick = () => showView("address"); $("connection-save").onclick = () => void run(async (signal) => { + const startedAt = navigationVersion; const lan = $("connection-lan").value.trim(), cloud = $("connection-cloud").value.trim(); const addresses = [lan, cloud] @@ -683,7 +735,10 @@ if (!addresses.length || addresses.some((value) => !value)) throw new Error("Check the shop code or address and try again."); if (POSNIC.session.managed) { - for (const base of addresses) await POSNIC.session.addAddress(base); + for (const base of addresses) { + if (signal.aborted || navigationVersion !== startedAt) return; + await POSNIC.session.addAddress(base, signal); + } } else { POSNIC.server.remember({ lan, cloud }); if (window.CaptainAccess?.locked) @@ -696,6 +751,7 @@ POSNIC.server.unpin(); } } + if (signal.aborted || navigationVersion !== startedAt) return; $("captain-server").value = POSNIC.server.baseUrl || addresses[0]; $("connection-back").hidden = false; note("Saved"); @@ -703,7 +759,8 @@ showView("start", false); showStep( POSNIC.server.isConfigured && - !sessionStorage.getItem("posnic_change_server"), + !sessionStorage.getItem("posnic_change_server") && + !sessionStorage.getItem("posnic_editing_server"), ); }); })(); diff --git a/assets/common/order-actions.js b/assets/common/order-actions.js index a945d8a..33e5279 100644 --- a/assets/common/order-actions.js +++ b/assets/common/order-actions.js @@ -47,42 +47,67 @@ } document.addEventListener('click',async event=>{ const button=event.target.closest('[data-handover-sale]');if(!button||button.disabled)return; - const dialog=document.createElement('dialog');dialog.className='captain-action-dialog'; - dialog.innerHTML=''; + if(document.querySelector('[data-handover-dialog]'))return; + const dialog=document.createElement('dialog');dialog.className='captain-action-dialog';dialog.dataset.handoverDialog=''; + dialog.innerHTML=''; document.body.append(dialog);root.I18N?.apply(dialog);dialog.showModal(); - const close=()=>{dialog.close();dialog.remove();button.focus();};dialog.querySelector('[type=button]').onclick=close; - dialog.oncancel=event=>{event.preventDefault();close();}; + let saving=false,loading=false,closed=false,request=null; + const finish=()=>{closed=true;window.removeEventListener('captain:back',nativeBack,true);dialog.close();dialog.remove();button.focus();}; + const close=()=>{if(!saving)finish();}; + const nativeBack=event=>{event.preventDefault();event.stopImmediatePropagation();close();}; + window.addEventListener('captain:back',nativeBack,true); + const cancel=dialog.querySelector('[data-close]'),retry=dialog.querySelector('[data-retry]'); + cancel.onclick=close;dialog.oncancel=event=>{event.preventDefault();close();}; const status=dialog.querySelector('[role=status]'),submit=dialog.querySelector('[type=submit]'),select=dialog.querySelector('select'); - try{ - const response=await POSNIC.api.get('/sales/handoverStaff'); - if(response.type!=='success'||!Array.isArray(response.data))throw new Error(response.message||'Could not load staff.'); - for(const staff of response.data){const option=document.createElement('option');option.value=staff.id;option.textContent=staff.name;option.translate=false;select.append(option);} - status.textContent='';submit.disabled=!select.options.length; - }catch(error){status.textContent=t(error.message);} - const requestId=crypto.randomUUID(); + select.onchange=()=>{submit.disabled=!select.value;}; + const load=async()=>{ + if(loading||closed)return;loading=true;retry.hidden=true;status.textContent=t('Loading...'); + try{ + const response=await POSNIC.api.get('/sales/handoverStaff'); + if(closed)return; + if(response.type!=='success'||!Array.isArray(response.data))throw new Error(response.message||'Could not load staff.'); + select.replaceChildren(); + const placeholder=document.createElement('option');placeholder.value='';placeholder.textContent=t('Choose a staff member.');select.append(placeholder); + for(const staff of response.data){const option=document.createElement('option');option.value=staff.id;option.textContent=staff.name;option.translate=false;select.append(option);} + select.disabled=!response.data.length;submit.disabled=true; + status.textContent=response.data.length?'':t('Choose an active staff member in this branch.');retry.hidden=!!response.data.length; + }catch(error){if(!closed){status.textContent=t(error.message||'Could not load staff.');retry.hidden=false;}} + finally{loading=false;} + }; + retry.onclick=load; dialog.querySelector('form').onsubmit=async event=>{ - event.preventDefault();if(!select.value)return;submit.disabled=true; + event.preventDefault();if(saving||!select.value)return; + if(!request||request.staffId!==select.value)request={saleId:button.dataset.handoverSale,branchId:button.dataset.handoverBranch,staffId:select.value,requestId:crypto.randomUUID()}; + saving=true;submit.disabled=true;select.disabled=true;cancel.disabled=true;dialog.setAttribute('aria-busy','true');status.textContent=t('Saving…'); try{ - const response=await POSNIC.api.post('/sales/handoverOrder',{saleId:button.dataset.handoverSale,branchId:button.dataset.handoverBranch,staffId:select.value,requestId}); - if(response.type!=='success')throw new Error(response.message||'Could not save. Please try again.'); + const response=await POSNIC.api.post('/sales/handoverOrder',request); + if(response.type!=='success'||!response.data?.staff)throw new Error(response.message||'Could not save. Please try again.'); const assignee=button.closest('.service-order-options')?.querySelector('.service-assignee');if(assignee)assignee.textContent=response.data.staff.name; - close(); - }catch(error){status.textContent=t(error.message);submit.disabled=false;} + finish(); + }catch(error){status.textContent=t(error.message||'Could not save. Please try again.');} + finally{saving=false;submit.disabled=false;select.disabled=false;cancel.disabled=false;dialog.removeAttribute('aria-busy');} }; + await load(); }); document.addEventListener('click',async event=>{ - const button=event.target.closest('[data-delivery-sale]');if(!button)return; - const dialog=document.createElement('dialog');dialog.className='captain-action-dialog'; - dialog.innerHTML='Loading...
'; document.body.append(dialog);root.I18N?.apply(dialog);dialog.showModal(); - const close=()=>{dialog.close();dialog.remove();button.focus();};dialog.querySelector('[data-close]').onclick=close;dialog.oncancel=event=>{event.preventDefault();close();}; - const body=dialog.querySelector('.delivery-body'),refresh=dialog.querySelector('[data-refresh]'); + let closed=false,loading=false; + const close=()=>{closed=true;window.removeEventListener('captain:back',nativeBack,true);dialog.close();dialog.remove();button.focus();}; + const nativeBack=event=>{event.preventDefault();event.stopImmediatePropagation();close();}; + window.addEventListener('captain:back',nativeBack,true); + dialog.querySelector('[data-close]').onclick=close;dialog.oncancel=event=>{event.preventDefault();close();}; + const body=dialog.querySelector('.delivery-body'),refresh=dialog.querySelector('[data-refresh]'),status=dialog.querySelector('.delivery-status'); const load=async()=>{ - refresh.disabled=true; + if(closed||loading)return;loading=true; + refresh.disabled=true;dialog.setAttribute('aria-busy','true');status.textContent=t('Loading...'); try{ const response=await POSNIC.api.get('/sales/kitchenDeliveryStatus?saleId='+encodeURIComponent(button.dataset.deliverySale)); - if(response.type!=='success')throw new Error(response.message||'Could not load delivery status.'); - body.replaceChildren(); + if(closed)return; + if(response.type!=='success'||!response.data||!Array.isArray(response.data.displays)||!Array.isArray(response.data.reports)||response.data.reports.some(report=>!Array.isArray(report.printers)))throw new Error(response.message||'Could not load delivery status.'); + body.replaceChildren();status.textContent=''; const add=(text,tag='p')=>{const element=document.createElement(tag);element.textContent=text;body.append(element);}; add(t('Order saved on server')); add(t('Kitchen display'),'h3'); @@ -99,8 +124,8 @@ } } add(t('Printer acceptance does not confirm paper output. Check the desktop printing log before reprinting.')); - }catch(error){body.textContent=t(error.message||'Could not load delivery status.');} - finally{refresh.disabled=false;} + }catch(error){if(!closed)status.textContent=t(error.message||'Could not load delivery status.');} + finally{loading=false;refresh.disabled=false;dialog.removeAttribute('aria-busy');} }; refresh.onclick=load;await load(); }); diff --git a/assets/common/order-queue-ui.js b/assets/common/order-queue-ui.js index b075d05..e6b6305 100644 --- a/assets/common/order-queue-ui.js +++ b/assets/common/order-queue-ui.js @@ -5,7 +5,7 @@ timer, lastError = ""; const ID = "posnic-unsent"; - const buttonStyle = 'min-height:44px;padding:8px 14px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#334155;font:600 14px system-ui;cursor:pointer;'; + const buttonStyle = 'min-height:44px;padding:8px 14px;border:1px solid var(--line,#cbd5e1);border-radius:8px;background:var(--surface,#fff);color:var(--ink,#334155);font:600 14px system-ui;cursor:pointer;'; const failure = (message, code) => Object.assign(new Error(message), { code }); function ownerMatches(row) { @@ -52,18 +52,25 @@ el = document.createElement("section"); el.id = ID; el.setAttribute("aria-label", "Orders awaiting delivery"); - el.style.cssText = - "position:relative;background:#f8fafc;color:#475569;border-bottom:1px solid #e2e8f0;padding:0 16px;font:13px/1.4 system-ui;max-height:45vh;overflow:auto;"; - el.innerHTML = `Sent to the kitchen
` : ''} ${round.items.map(line => `${esc(line.name)} × ${line.quantity}
${line.note?`${esc(line.note)}`:''}${line.served?`${t('Served')}: ${line.served}`:''}${t('Table')} ${destination.tableIds.map(id=>esc(tables.find(row=>row.id===id)?.tableorder_value||id)).join(', ')}
${t('Main table')}: ${esc(tables.find(row=>row.id===destination.primaryId)?.tableorder_value||'')}
${t('Guests')}: ${destination.guests}
${t('Total')}: ${esc(money(preview.destination.totalMinor))}
${t('Total')}: ${esc(money(preview.source.totalMinor))}
${t('Reconnect to the server that authorized this phone. Orders are retained.')}
`; + dialog.innerHTML=`${esc(t("Loading..."))}
`; + if (view === "profile") void load(); + } + function actions() { + return `${esc(t("Loading..."))}
`; + try { + const loaded = await Promise.race([ + Promise.resolve().then(() => POSNIC.api.get("/captain/v1/profile")), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('profile_timeout')), 20000); }), + ]); + if (current !== "profile" || requestGeneration !== generation) return; + profile = loaded; + renderProfile(); + } catch { + if (current !== "profile" || requestGeneration !== generation) return; + page.innerHTML = `${esc(t("Connection failed"))}
No orders yet.
'; } } - function saySomethingWentWrong(why) { + function clearFigures() { const tables = at('sales-tables'); const recent = at('sales-recent'); - const note = '' + escape(why) + '
'; + const note = ''; if (tables) tables.innerHTML = note; if (recent) recent.innerHTML = note; const total = at('sales-total'); if (total) total.textContent = ''; + at('sales-paid-row').hidden = true; + at('sales-paid').textContent = ''; + at('sales-count').textContent = ''; + at('sales-cancelled').textContent = ''; + at('sales-cancelled').hidden = true; } let selectedDay = 'today', revision = 0, loadedDay = null; @@ -106,16 +115,27 @@ const request = ++revision; const day = new Date(); if (which === 'yesterday') day.setDate(day.getDate() - 1); - + const dateKey = stamp(day); + if (loadedDay !== dateKey) { + loadedDay = null; + clearFigures(); + } + at('sales-status').textContent = t('Loading...'); + document.querySelector('main').setAttribute('aria-busy', 'true'); + let timer; try { - const said = await POSNIC.api.post('/sales/myDay', { - branch_id: localStorage.getItem('branch_id') || '', - day: stamp(day), - }); + const said = await Promise.race([ + Promise.resolve().then(() => POSNIC.api.post('/sales/myDay', { + branch_id: localStorage.getItem('branch_id') || '', + day: dateKey, + })), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('sales_timeout')), 20000); }), + ]); if (request !== revision) return false; if (said?.type !== 'success' || !said.data) throw new Error('Connection failed'); draw(said.data); - loadedDay = which; + loadedDay = dateKey; + at('sales-status').textContent = ''; return true; } catch (error) { /* @@ -123,15 +143,24 @@ * difference matters on a screen about money. */ if (request !== revision) return false; - if (loadedDay !== which) saySomethingWentWrong( - (error && error.message) || 'The till did not answer. Try again in a moment.' - ); + const message = t('The till did not answer. Try again in a moment.'); + at('sales-status').textContent = message; + if (loadedDay !== dateKey) clearFigures(); return false; + } finally { + clearTimeout(timer); + if (request === revision) document.querySelector('main').removeAttribute('aria-busy'); } } window.refreshMySales = () => load(selectedDay); + window.addEventListener('captain:back', event => { + if (event.defaultPrevented || document.querySelector('dialog[open], #posnic-lock.is-open')) return; + event.preventDefault(); + location.href = 'me.html'; + }); document.addEventListener('DOMContentLoaded', function () { + at('sales-pending').addEventListener('click', () => sessionStorage.setItem('captain_pending_return', 'my-sales.html')); const back = at('sales-back'); if (back) { back.addEventListener('click', function () { @@ -143,6 +172,7 @@ button.addEventListener('click', function () { document.querySelectorAll('.sales-day').forEach(function (other) { other.classList.toggle('is-on', other === button); + other.setAttribute('aria-pressed', String(other === button)); }); load(button.getAttribute('data-day')); }); diff --git a/assets/me/script.js b/assets/me/script.js index 4f976a1..31a9b06 100644 --- a/assets/me/script.js +++ b/assets/me/script.js @@ -35,8 +35,10 @@ } catch (e) { name = ''; } - line.translate = !name; - line.textContent = name || 'Signed in on this phone'; + for (const target of [line, at('me-home-who')].filter(Boolean)) { + target.translate = !name; + target.textContent = name || 'Signed in on this phone'; + } } /* @@ -87,8 +89,10 @@ const row = at('me-lock'); const off = at('me-lock-off'); if (row) { - row.addEventListener('click', function () { - if (window.POSNIC && POSNIC.lock) POSNIC.lock.choose().then(paintLock); + row.addEventListener('click', async function () { + if (!(window.POSNIC && POSNIC.lock)) return; + if (POSNIC.lock.isSet() && !await POSNIC.lock.unlock('', {why:'Enter your PIN',escape:'Not now'})) return; + await POSNIC.lock.choose(); paintLock(); }); } if (off) { @@ -115,8 +119,13 @@ const COPIES = 'posnic.bill_copies'; function paintPreferences() { + for (const name of ['sound', 'vibration']) at('me-' + name).checked = CaptainPhone.enabled(name); + at('me-vibration-row').hidden = typeof navigator.vibrate !== 'function'; const language = at('me-language'); - if (language && typeof I18N !== 'undefined') language.value = I18N.language(); + if (language && typeof I18N !== 'undefined') { + language.value = I18N.language(); + at('me-language-name').textContent = language.selectedOptions[0]?.textContent || ''; + } const copies = at('me-copies'); if (copies) { @@ -150,10 +159,18 @@ } function wirePreferences() { + for (const name of ['sound', 'vibration']) { + at('me-' + name).addEventListener('change', event => { + const saved = CaptainPhone.set(name, event.target.checked); + if (!saved) event.target.checked = CaptainPhone.enabled(name); + at('me-preference-message').textContent = saved ? '' : I18N.t('Could not save. Please try again.'); + }); + } const language = at('me-language'); if (language) { language.addEventListener('change', function () { if (typeof I18N !== 'undefined') I18N.use(language.value); + at('me-language-name').textContent = language.selectedOptions[0]?.textContent || ''; }); } @@ -189,38 +206,10 @@ /* ------------------------------------------------------- the account */ function wireAccount() { - const password = at('me-password'); - if (password) { - password.addEventListener('click', function () { - /* - * NOT YET, AND SAID PLAINLY RATHER THAN HIDDEN. - * - * The till has an endpoint for it, but it stores a password - * base64-encoded before hashing and that endpoint writes it - * raw. A password changed from here would still sign in on - * this phone and on the till's main login, and would fail the - * super-admin check - a half-broken account is worse than a - * row that says where to go. - */ - if (typeof showErrorPopup === 'function') { - showErrorPopup('Ask the shop to change your password on the till. It cannot be changed from a phone yet.'); - } else { - alert('Ask the shop to change your password on the till.'); - } - }); - } - at('me-sign-out')?.addEventListener('click', () => CaptainAccount.change('staff')); } document.addEventListener('DOMContentLoaded', function () { - const back = at('me-back'); - if (back) { - back.addEventListener('click', function () { - window.location.href = 'kot-management.html'; - }); - } - paintWho(); paintLock(); paintPreferences(); diff --git a/assets/me/style.css b/assets/me/style.css index c63dfd9..ad9c9fd 100644 --- a/assets/me/style.css +++ b/assets/me/style.css @@ -205,3 +205,30 @@ border-color: var(--ink); color: var(--surface); } + +.help-gestures summary { + min-height: 48px; + padding: 14px 16px; + cursor: pointer; + font-weight: 600; + color: var(--ink); + overflow-wrap: anywhere; +} +.help-gestures summary:focus-visible { + outline: 2px solid var(--ink); + outline-offset: -3px; + border-radius: var(--radius); +} +.help-gestures ul { + margin: 0; + padding-block: 0 16px; + padding-inline: 36px 18px; + color: var(--ink-soft); + line-height: 1.6; +} +.help-gestures li + li { margin-block-start: 12px; } +[dir="rtl"] .me-back { transform: scaleX(-1); } +.me-printer-info { padding: 16px; } +.me-printer-info + .me-printer-info { margin-block-start: 12px; } +.me-printer-info .me-heading { margin-block-start: 0; color: var(--ink); font-size: var(--t-md); letter-spacing: normal; text-transform: none; } +.me-printer-info .me-note { margin-block-end: 0; } diff --git a/assets/order-history/editor.css b/assets/order-history/editor.css index 36508f2..c19bf42 100644 --- a/assets/order-history/editor.css +++ b/assets/order-history/editor.css @@ -60,3 +60,8 @@ #editOrderModal .editor-footer-actions #save-order-changes { flex:1; width:auto; } .editor-cancel { flex:0 0 auto; min-height:48px; padding:12px 18px; border:1px solid var(--line,#dce3eb); border-radius:12px; background:#fff; color:var(--ink,#162338); font:inherit; font-weight:600; } .editor-cancel:disabled { opacity:.5; } + +#edit-discount-section input, #edit-discount-section .btn { min-height:48px; } +#edit-discount-section .btn { display:flex; align-items:center; justify-content:center; min-width:48px; } +#edit-discount-section textarea { min-height:104px; } +#edit-discount-section { padding-bottom:16px; } diff --git a/assets/order-history/editor.js b/assets/order-history/editor.js index 3630d7c..dad4295 100644 --- a/assets/order-history/editor.js +++ b/assets/order-history/editor.js @@ -2,9 +2,12 @@ (function () { 'use strict'; const byId = id => document.getElementById(id); - let view = 'items', setting = '', beforeSetting = null, initial = null; + let view = 'items', setting = '', beforeSetting = null, initial = null, originalOrder = null; let originalLines = new WeakSet(), saving = false, saved = false, cancelling = false; let ownsHistory = false; + let settingLoad = 0, legacyGuestSupported = false; + const identity = () => JSON.stringify([POSNIC.session?.shopKey, POSNIC.session?.user?.id, localStorage.getItem('branch_id'), POSNIC.session?.base || POSNIC.server?.baseUrl]); + const durableGuests = () => !!(order()?.seating_request_id || legacyGuestSupported || (order() && CaptainGuestUpdate.pending(order()._id))); const historyKey = 'captainOrderEditor'; const selected = name => document.querySelector('input[name="' + name + '"]:checked'); const order = () => orderBeingModified(); @@ -16,6 +19,10 @@ table: table?.id === 'edit_table_manual_radio' ? byId('edit_manual_table_input').value.trim().toUpperCase() : table?.value || order()?.table_number || order()?.kiosk_table_no || '', manual: byId('edit_manual_table_input')?.value || '', guests: order()?.person_count || 1, + discount: byId('edit-discount-value')?.value || '0', + discountType: selected('edit_discount_type')?.value || 'amount', + discountReason: byId('edit-discount-description')?.value || '', + }; } function fingerprint() { @@ -26,8 +33,12 @@ document.querySelectorAll('input[name="edit_table_no"]').forEach(input => input.checked = input.id === value.tableRadio); if (byId('edit_manual_table_input')) byId('edit_manual_table_input').value = value.manual; setEditPersonCount(value.guests); + byId('edit-discount-value').value = value.discount; + byId('edit-discount-description').value = value.discountReason; + document.querySelectorAll('input[name="edit_discount_type"]').forEach(input => input.checked = input.value === value.discountType); } function show(next) { + settingLoad++; view = next; for (const name of ['items', 'details', 'settings']) byId('order-editor-' + name).hidden = name !== next; document.querySelector('#editOrderModal .editor-footer').hidden = next !== 'items'; @@ -39,6 +50,7 @@ function typeChanged() { if (view !== 'settings') return; byId('edit-type-section').hidden = setting !== 'type'; + byId('edit-discount-section').hidden = setting !== 'discount'; // Converting a takeaway to dine-in includes choosing its destination. const tableNeeded = setting === 'table' || (setting === 'type' && details().type === 'Dine-in'); byId('edit-table-section').hidden = !tableNeeded; @@ -46,9 +58,62 @@ byId('edit-table-section').style.display = ''; byId('edit-pax-section').style.display = ''; } + let priceKey = '', priceGeneration = 0, priceTimer, priceState = 'idle', confirmedTotal; + function resetPrice() { + clearTimeout(priceTimer); priceGeneration++; priceKey = ''; priceState = 'idle'; confirmedTotal = undefined; + } + function pricing(value) { + const current = order(); + if (!current?.pricing_preview) return; + const priorType = ['amount','price','fixed'].includes(String(originalOrder?.extra_discount_type || '').toLowerCase()) ? 'amount' : 'percent'; + const discount = Number(value.discount), changed = discount !== Number(originalOrder?.extra_discount || 0) || (discount !== 0 && value.discountType !== priorType); + const preserve = current.transfer_allocated === true && !changed; + const body = { order_id: current._id, items: linesForSave(current.items), + extra_discount_type: preserve ? null : value.discountType, extra_discount: preserve ? null : discount, + discount_description: value.discountReason, seen_at: orderSeenAt(current) }; + const owner = identity(), key = JSON.stringify([owner, body]); + if (key === priceKey) return; + clearTimeout(priceTimer); priceKey = key; priceState = 'loading'; confirmedTotal = undefined; + const generation = ++priceGeneration; + priceTimer = setTimeout(async () => { + let deadline; + try { + const result = await Promise.race([ + POSNIC.api.post('/captain/v1/orders/edit/preview', body), + new Promise((_, reject) => { deadline = setTimeout(() => reject(new Error('timeout')), 20000); }) + ]); + if (generation !== priceGeneration || current !== order() || owner !== identity()) return; + if (typeof result.total_amount !== 'number' || !Number.isFinite(result.total_amount)) throw new Error('invalid_total'); + confirmedTotal = result.total_amount; priceState = 'ready'; + } catch { + if (generation !== priceGeneration || current !== order() || owner !== identity()) return; + priceState = 'error'; + } finally { clearTimeout(deadline); } + refresh(); + }, 300); + } + function reviewTotal(value) { + const current = order(); + if (!originalOrder || !current) return Number(current?.total_amount || 0); + const financialLines = items => JSON.stringify((items || []).map(item => [ + item.line_id || item.product_id || item.item_id || item.id || item._id, + Number(item.quantity ?? item.item_quantity ?? 0), Number(item.price ?? item.unit_price ?? 0), + !!item.cancelled, !!item.return, item.status + ])); + // The server supplies the exact pre-discount taxable basis for allocated + // bills. Use its confirmed amounts while only discount/notes change. + if (current.transfer_allocated !== true || !Number.isFinite(Number(originalOrder.discount_basis)) || + financialLines(current.items) !== financialLines(originalOrder.items)) return Number(current.total_amount || 0); + const policy = CaptainMoney.current(); + const before = CaptainMoney.toMinor(originalOrder.total_amount,policy) + CaptainMoney.toMinor(originalOrder.extra_discount || 0,policy); + const requested = value.discountType === 'percent' ? Number(originalOrder.discount_basis)*Number(value.discount)/100 : Number(value.discount); + if (!Number.isFinite(requested) || requested < 0) return Number(current.total_amount || 0); + return CaptainMoney.fromMinor(before-Math.min(before,CaptainMoney.toMinor(requested,policy)),policy); + } function refresh() { if (!byId('order-editor-items') || !order()) return; const value = details(), dineIn = value.type === 'Dine-in'; + pricing(value); byId('order-editor-context').textContent = dineIn && value.table ? 'Table ' + value.table : 'Takeaway'; const type = document.createElement('span'); type.textContent = value.type; const guests = document.createElement('span'); guests.textContent = 'Guests'; @@ -57,46 +122,183 @@ byId('editor-table-value').textContent = value.table; byId('editor-guests-value').textContent = value.guests; byId('editor-type-value').textContent = value.type; + byId('editor-discount-value').textContent = value.discountType === 'percent' ? value.discount + '%' : CaptainMoney.display(Number(value.discount)); document.querySelector('[data-editor-setting="table"]').hidden = !dineIn; document.querySelector('[data-editor-setting="guests"]').hidden = !dineIn; const count = (order().items || []).filter(item => !lineIsCancelled(item, order())).reduce((sum, item) => sum + Number(item.quantity || item.item_quantity || 0), 0); for (const prefix of ['editor', 'picker']) { byId(prefix + '-item-count').textContent = count === 1 ? '1 item' : count + ' items'; - byId(prefix + '-total-value').textContent = CaptainMoney.display(order().total_amount || 0); + const total = byId(prefix + '-total-value'); + total.textContent = order().pricing_preview ? (priceState === 'ready' ? CaptainMoney.display(confirmedTotal) : priceState === 'loading' ? '…' : '—') : CaptainMoney.display(reviewTotal(value)); + total.setAttribute('aria-busy', String(!!order().pricing_preview && priceState === 'loading')); + let retry = byId(prefix + '-price-retry'); + if (!retry) { + retry = document.createElement('button'); retry.id = prefix + '-price-retry'; retry.type = 'button'; + retry.classList.add('btn', 'btn-sm', 'btn-link'); retry.textContent = window.I18N?.t('Retry') || 'Retry'; + retry.addEventListener('click', () => { resetPrice(); refresh(); }); total.after(retry); + } + retry.hidden = !order().pricing_preview || priceState !== 'error'; } byId('cancel-order-changes').disabled = saving; byId('save-order-changes').disabled = saving || (initial !== null && initial === fingerprint()); } function begin() { + resetPrice(); + legacyGuestSupported = false; settingLoad++; saved = false; saving = false; beforeSetting = null; + originalOrder = JSON.parse(JSON.stringify(order())); + document.querySelectorAll('#edit-type-section input, #edit-table-section input, #edit-pax-section input, #edit-pax-section button').forEach(input => input.disabled=false); originalLines = new WeakSet(order()?.items || []); initial = fingerprint(); show('items'); } - function openSetting(name) { + async function openSetting(name) { + if (saving) return; + if (name === 'guests' && order() && !durableGuests()) { + const revision = ++settingLoad, current = order(), owner = identity(); + const button = document.querySelector('[data-editor-setting="guests"]'); + button.disabled = true; + try { + const result = await POSNIC.api.get('/captain/v1/tables'); + if (revision !== settingLoad || order() !== current || identity() !== owner || !byId('editOrderModal').classList.contains('show')) return; + legacyGuestSupported = result.capabilities?.legacyGuestUpdate === true; + } catch (error) { + if (revision === settingLoad && order() === current) showToast(window.I18N?.t('Connection failed') || 'Connection failed', 'error'); + return; + } finally { button.disabled = false; } + } + if (order() && (name === 'table' || (name === 'guests' && durableGuests()) || (order().seating_request_id && name === 'type'))) { + const pending = CaptainGroupMove.pending(order()._id); + if (pending) name = pending.body.dineType ? 'type' : 'table'; + if (CaptainGuestUpdate.pending(order()._id)) name = 'guests'; + if (initial !== null && fingerprint() !== initial) { + if (!await CaptainConfirm.discard()) return; + setOrderBeingModified(JSON.parse(JSON.stringify(originalOrder))); + restore(JSON.parse(initial).details); + renderCurrentOrderItems(); + } + if (name === 'table') { + const id = order()._id, modal = byId('editOrderModal'); + saved = true; + modal.addEventListener('hidden.bs.modal', () => moveOrder(id), {once:true}); + bootstrap.Modal.getInstance(modal)?.hide(); + return; + } + } setting = name; beforeSetting = details(); - byId('editor-setting-title').textContent = { table: 'Move table', guests: 'Guests', type: 'Order Type' }[name]; + byId('editor-setting-title').textContent = { table: 'Move table', guests: 'Guests', type: 'Order Type', discount: 'Discount' }[name]; show('settings'); typeChanged(); + if (order()?.seating_request_id && name === 'type') { + const pending = CaptainGroupMove.pending(order()._id); + if (pending?.body.dineType) { + document.querySelectorAll('input[name="edit_dine_type"]').forEach(input => input.checked = input.value === pending.body.dineType); + typeChanged(); + } + byId('editor-setting-apply').textContent = window.I18N?.t(pending ? 'Retry' : 'Save') || (pending ? 'Retry' : 'Save'); + document.querySelectorAll('#edit-type-section input, #edit-table-section input').forEach(input => input.disabled=!!pending); + } else if (name === 'guests' && durableGuests()) { + const pending = CaptainGuestUpdate.pending(order()._id); + if (pending) setEditPersonCount(pending.body.guests); + byId('editor-setting-apply').textContent = window.I18N?.t(pending ? 'Retry' : 'Save') || (pending ? 'Retry' : 'Save'); + document.querySelectorAll('#edit-pax-section input, #edit-pax-section button').forEach(input => input.disabled=!!pending); + } else byId('editor-setting-apply').textContent = window.I18N?.t('Apply') || 'Apply'; } function back() { + if (saving) return; if (beforeSetting) restore(beforeSetting); beforeSetting = null; show('details'); } - function apply() { + async function apply() { + if (saving) return; const value = details(); + if (setting === 'discount') { + const input = byId('edit-discount-value'); + input.max = value.discountType === 'percent' ? '100' : ''; + input.step = value.discountType === 'percent' ? '0.01' : String(1 / CaptainMoney.current().factor); + if (!input.reportValidity()) return; + const reason = byId('edit-discount-description'); + reason.value = reason.value.trim(); + reason.required = value.discount !== beforeSetting.discount || value.discountType !== beforeSetting.discountType; + reason.minLength = 3; reason.maxLength = 200; + if (!reason.reportValidity()) return; + } if (value.type === 'Dine-in' && !value.table) { showToast('Choose a table first.', 'error'); return; } if (value.type === 'Dine-in' && value.tableRadio === 'edit_table_manual_radio' && !/^[A-Z0-9]{1,6}$/.test(value.table)) { showToast('Table must be 1–6 letters/numbers (A–Z, 0–9).', 'error'); return; } + if (setting === 'guests' && durableGuests()) { + const current = order(), controls = [...byId('editOrderModal').querySelectorAll('button,input,select')]; + const disabled = controls.map(control => control.disabled); + saving=true; controls.forEach(control=>control.disabled=true); + try { + if (CaptainGuestUpdate.pending(current._id)) await CaptainGuestUpdate.resume(current._id); + else await CaptainGuestUpdate.save(current._id,value.guests); + saved=true; + bootstrap.Modal.getInstance(byId('editOrderModal'))?.hide(); + await loadOrderHistory({background:true}); + showToast(window.I18N?.t('Saved') || 'Saved','success'); + } catch(error) { + showToast(error.message || 'Could not save. Please try again.','error'); + } finally { + saving=false; controls.forEach((control,index)=>control.disabled=disabled[index]); + try { + const pending=CaptainGuestUpdate.pending(current._id); + byId('editor-setting-apply').textContent=window.I18N?.t(pending?'Retry':'Save') || (pending?'Retry':'Save'); + document.querySelectorAll('#edit-pax-section input, #edit-pax-section button').forEach(input=>input.disabled=!!pending); + } catch { + saved=true; bootstrap.Modal.getInstance(byId('editOrderModal'))?.hide(); + } + refresh(); + } + return; + } + if (setting === 'type' && order()?.seating_request_id && + (value.type !== (originalOrder.dine_type || 'Dine-in') || CaptainGroupMove.pending(order()._id))) { + const current = order(), takeaway = value.type === 'Take away'; + const selectedTable = selected('edit_table_no'); + const sameTable = value.table === originalOrder.table_number; + const tableIds = takeaway ? [] : sameTable ? originalOrder.seating_table_ids : [selectedTable?.dataset.id].filter(Boolean); + const primaryId = takeaway ? '' : sameTable ? originalOrder.seating_primary_id : tableIds[0]; + const pending = CaptainGroupMove.pending(current._id); + if (!pending && !takeaway && (!tableIds?.length || !primaryId)) { showToast('Choose a table first.', 'error'); return; } + saving = true; + const controls = [...byId('editOrderModal').querySelectorAll('button,input,select')]; + const disabled = controls.map(control => control.disabled); + controls.forEach(control => control.disabled=true); + try { + if (pending) await CaptainGroupMove.resume(current._id); + else await CaptainGroupMove.move(current._id,{tableIds,primaryId,guests:takeaway?0:Math.max(1,value.guests),dineType:value.type}); + saved = true; + bootstrap.Modal.getInstance(byId('editOrderModal'))?.hide(); + await loadOrderHistory({background:true}); + showToast(window.I18N?.t('Saved') || 'Saved', 'success'); + } catch (error) { + showToast(error.message || 'Could not save. Please try again.', 'error'); + byId('editor-setting-apply').textContent=window.I18N?.t('Retry') || 'Retry'; + } finally { + saving=false; + controls.forEach((control,index)=>control.disabled=disabled[index]); + try { + const unresolved = CaptainGroupMove.pending(current._id); + if (unresolved) document.querySelectorAll('#edit-type-section input, #edit-table-section input').forEach(input=>input.disabled=true); + } catch { + // Session expiry must not leave a rejected cleanup promise or editable stale order. + saved = true; + bootstrap.Modal.getInstance(byId('editOrderModal'))?.hide(); + } + refresh(); + } + return; + } beforeSetting = null; show('items'); } - function cancel() { + async function cancel() { if (saving) return; - if (initial !== null && fingerprint() !== initial && !window.confirm(window.I18N ? I18N.t('Discard changes?') : 'Discard changes?')) return; + if (initial !== null && fingerprint() !== initial && !await CaptainConfirm.discard()) return; cancelling = true; bootstrap.Modal.getInstance(byId('editOrderModal'))?.hide(); cancelling = false; @@ -113,6 +315,23 @@ document.addEventListener('DOMContentLoaded', () => { const modal = byId('editOrderModal'); if (!modal) return; + const discountSection = document.createElement('div'); + discountSection.id = 'edit-discount-section'; discountSection.hidden = true; + for (const id of ['edit-discount-value','edit-discount-description']) { + const row = byId(id).closest('.row'); row.style.display = ''; discountSection.append(row); + } + byId('editor-setting-apply').before(discountSection); + const amountLabel = modal.querySelector('label[for="edit-discount-amount"]'); + amountLabel.textContent = CaptainMoney.current().currencySymbol; + amountLabel.setAttribute('translate','no'); + byId('edit-discount-value').step = String(1 / CaptainMoney.current().factor); + byId('edit-discount-value').required = true; + byId('edit-discount-value').inputMode = 'decimal'; + byId('edit-discount-description').placeholder = window.I18N?.t('Reason for change') || 'Reason for change'; + discountSection.querySelector('label.form-label').htmlFor = 'edit-discount-value'; + byId('edit-discount-description').previousElementSibling.htmlFor = 'edit-discount-description'; + byId('edit-discount-description').previousElementSibling.textContent = window.I18N?.t('Reason for change') || 'Reason for change'; + modal.addEventListener('show.bs.modal', () => { if (ownsHistory) return; history.pushState({ ...history.state, [historyKey]: true }, '', location.href); @@ -129,7 +348,7 @@ } }); window.addEventListener('captain:back', event => { - if (document.querySelector('#guest-bills[open]') || !modal.classList.contains('show')) return; + if (event.defaultPrevented || document.querySelector('dialog[open], #posnic-lock.is-open') || !modal.classList.contains('show')) return; event.preventDefault(); const nested = [...document.querySelectorAll('.modal.show')].filter(node => node !== modal).pop(); bootstrap.Modal.getInstance(nested || modal)?.hide(); @@ -140,9 +359,13 @@ if (!byId('item-picker').hidden) { event.preventDefault(); closeItemPicker(); return; } if (view === 'settings') { event.preventDefault(); back(); return; } if (view === 'details') { event.preventDefault(); show('items'); return; } - if (initial !== null && fingerprint() !== initial && !window.confirm(window.I18N ? I18N.t('Discard changes?') : 'Discard changes?')) event.preventDefault(); + if (initial !== null && fingerprint() !== initial) { + event.preventDefault(); + void cancel(); + } }); modal.addEventListener('hidden.bs.modal', () => { + resetPrice(); if (ownsHistory && history.state?.[historyKey]) { ownsHistory = false; history.back(); } initial = null; setOrderBeingModified(null); diff --git a/assets/order-history/script.js b/assets/order-history/script.js index 5b3aa5e..33b26bb 100644 --- a/assets/order-history/script.js +++ b/assets/order-history/script.js @@ -16,9 +16,8 @@ let _orderHistoryPollInterval = null; function startOrderHistoryPolling() { if (_orderHistoryPollInterval) return; _orderHistoryPollInterval = setInterval(() => { - const selScreen = document.getElementById('table-selection-screen'); - if (!selScreen || selScreen.style.display === 'none') return; // only on screen 1 - if (currentOrderId || editingOrder) return; // skip while editing + if (document.hidden || !document.getElementById('order-list-screen')) return; + if (document.querySelector('#orderDetailsModal.show') || editingOrder) return; loadOrderHistory(); }, 10000); } @@ -48,7 +47,7 @@ document.addEventListener('DOMContentLoaded', function () { // screen. Do not fetch hidden history or show its errors over the floor. if (document.getElementById('table-selection-screen')) loadOrderHistory(); setupEventListeners(); - showTableSelectionScreen(); // Start with table selection + if (document.getElementById("table-selection-screen")) showOrderListScreen("all"); restoreTableFilterState(); // Set filters to collapsed by default startOrderHistoryPolling(); }); @@ -62,7 +61,7 @@ function showTableSelectionScreen() { list.style.display = 'none'; const headerElement = document.getElementById('header-title'); if (headerElement) { - headerElement.textContent = 'Select Table'; + headerElement.textContent = window.I18N?.t('Select Table') || 'Select Table'; } document.getElementById('refresh-btn').style.display = 'block'; selectedTable = null; @@ -84,7 +83,7 @@ function showOrderListScreen(tableNumber) { } const headerElement = document.getElementById('header-title'); if (headerElement) { - headerElement.textContent = headerTitle; + headerElement.textContent = window.I18N?.t(headerTitle) || headerTitle; } document.getElementById('refresh-btn').style.display = 'block'; @@ -92,13 +91,8 @@ function showOrderListScreen(tableNumber) { } function handleBackButton() { - if (selectedTable !== null) { - // On order list screen - go back to table selection - showTableSelectionScreen(); - } else { - // On table selection screen - go back to previous page - goBack(); - } + if (selectedTable === 'all') location.href = 'kot-management.html'; + else showOrderListScreen('all'); } function goBack() { @@ -179,25 +173,15 @@ function generateTableCards() { // Filter orders by selected table function filterOrdersBySelectedTable() { - if (selectedTable === 'all') { - filteredOrders = allOrders.filter(order => { - const matchesStatus = currentFilter === 'all' || order.status === currentFilter; - return matchesStatus; - }); - } else if (selectedTable === 'TA') { - // Filter for takeaway orders - filteredOrders = allOrders.filter(order => { - const isTakeaway = order.dine_type === 'Take away' || order.dine_type === 'Takeaway'; - const matchesStatus = currentFilter === 'all' || order.status === currentFilter; - return isTakeaway && matchesStatus; - }); - } else { - filteredOrders = allOrders.filter(order => { - const matchesTable = order.table_number == selectedTable; - const matchesStatus = currentFilter === 'all' || order.status === currentFilter; - return matchesTable && matchesStatus; - }); - } + const search = String(document.getElementById('order-search')?.value || '').trim().toLocaleLowerCase(); + filteredOrders = allOrders.filter(order => { + const table = String(order.table_number || ''); + const takeaway = ['Take away','Takeaway'].includes(order.dine_type); + const chosen = selectedTable && selectedTable !== 'all' ? selectedTable : currentTableFilter; + const matchesTable = chosen === 'all' || !chosen || (chosen === 'TA' ? takeaway : table === String(chosen)); + const matchesSearch = !search || [order.order_id, table, order.customer_name].some(value => String(value || '').toLocaleLowerCase().includes(search)); + return matchesTable && matchesSearch && (currentFilter === 'all' || order.status === currentFilter); + }); renderOrders(); } @@ -213,12 +197,8 @@ function setupEventListeners() { document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); this.classList.add('active'); currentFilter = this.dataset.status; - if (selectedTable !== null) { - // On order list screen - filter by selected table - filterOrdersBySelectedTable(); - } else { - loadOrderHistory(); - } + filterOrdersBySelectedTable(); + loadOrderHistory({background:true}); }); }); @@ -336,6 +316,7 @@ async function loadOrderHistoryNow(options, revision) { if (data.type === 'success') { historyLoaded = true; allOrders = data.data.orders || []; + if(allOrders.length) void refreshMergePermission(revision); generateTableCards(); // Generate table selection cards if (selectedTable !== null) { // If on order list screen, filter by selected table @@ -464,14 +445,14 @@ async function saveOrderChanges() { const desc = document.getElementById('edit-discount-description').value.trim(); - let extraType = ''; - let extraVal = 0; - - if (discountValue > 0) { - extraType = discountType; // 'percent' or 'amount' - extraVal = discountValue; - } const order = allOrders.find(o => o._id === currentOrderId) || {}; + const priorType = ['amount', 'price', 'fixed'].includes(String(order.extra_discount_type || '').toLowerCase()) ? 'amount' : 'percent'; + const priorValue = Number(order.extra_discount || 0); + // Hidden discount controls must not replace a bill discount during an item edit. + const discountChanged = discountValue !== priorValue || (discountValue !== 0 && discountType !== priorType); + const preserveAllocation = order.transfer_allocated === true && !discountChanged; + const extraType = preserveAllocation ? null : discountType; + const extraVal = preserveAllocation ? null : discountValue; const dineTypeRadio = document.querySelector('input[name="edit_dine_type"]:checked'); const dineType = dineTypeRadio ? dineTypeRadio.value : (order.dine_type || 'Dine-in'); const tableRadio = document.querySelector('input[name="edit_table_no"]:checked'); @@ -608,24 +589,7 @@ async function saveOrderChanges() { } // Filter orders -function filterOrders() { - const searchInput = document.getElementById('order-search'); - const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; - - filteredOrders = allOrders.filter(order => { - const matchesSearch = !searchTerm || - order.order_id.toLowerCase().includes(searchTerm) || - order.table_number.toString().includes(searchTerm) || - (order.customer_name && order.customer_name.toLowerCase().includes(searchTerm)); - - const matchesStatus = currentFilter === 'all' || order.status === currentFilter; - const matchesTable = currentTableFilter === 'all' || order.table_number.toString() === currentTableFilter; - - return matchesSearch && matchesStatus && matchesTable; - }); - - renderOrders(); -} +function filterOrders() { filterOrdersBySelectedTable(); } // Generate dynamic table filter buttons function generateTableFilterButtons() { @@ -731,6 +695,7 @@ function restoreTableFilterState() { // Render orders function renderOrders() { + const safe = value => String(value ?? '').replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); const container = document.getElementById('orders-list'); const emptyState = document.getElementById('empty-state'); @@ -746,50 +711,53 @@ function renderOrders() { const ordersHtml = filteredOrders.map(order => `| Item | -Qty | -Price | -
|---|---|---|
|
- ${item.name}
- ${item.item_description
- ? ` ${item.item_description} `
- : ''
- }
- |
- ${item.quantity} | -${CaptainMoney.html(item.price)} | -
| - | Subtotal: | -${CaptainMoney.html(subtotal)} | -
| - | Discount: | --${CaptainMoney.html(discount)} | -
| - | Tax: | -${CaptainMoney.html(tax)} | -
| - | Total Amount: | -${CaptainMoney.html(order.total_amount)} | -
Table ${safe(order.table_number)} · ${safe(order.dine_type || 'Dine-in')} · Guests ${safe(order.person_count || 1)}
+${safe(order.status)} ·
+ ${order.customer_name ? `${safe(order.customer_name)}
` : ''} +${safe(order.discount_description)}