openPunch(s.id)); return on.length===1?on[0].name:(on.length>1?"(multiple on shift)":""); }catch(e){ return ""; } }
function logActivity(type, detail, orderNo, amt){
try{ DB=DB||loadDB(); DB.activity=DB.activity||[];
@@ -888,7 +940,7 @@
const done = (storesOn() && reportStore) ? dayAll.filter(r=>r.storeId===reportStore) : dayAll;
const L=(lab,val,bold)=>""+(bold?"":"")+lab+(bold?"":"")+""+(bold?"":"")+val+(bold?"":"")+"
";
// ---- daily sales summary (refund-aware: a refunded order's sale nets out, its reversed tender nets its tenders) ----
- let net=0, tax=0, tips=0, disc=0, items=0, refunds=0, orders=0, cogs=0, roundUp=0; const byTender={}, byCat={}, byItem={}, byHour={};
+ let net=0, tax=0, tips=0, disc=0, items=0, refunds=0, orders=0, cogs=0, roundUp=0, comps=0; const byTender={}, byCat={}, byItem={}, byHour={};
const itemCost=(l)=>{ const it=FLOW.catalog.find(i=>i.id===l.catId); return (it&&+it.cost)||0; };
done.forEach(r=>{ const rf=r.status==="REFUNDED";
if(rf){ refunds += recordTotal(r); }
@@ -897,6 +949,7 @@
net += recordNet(r)*frac; tax += recordTax(r)*frac; tips += r.tip||0; disc += recordDiscount(r)*frac; roundUp += r.roundUp||0;
if(r.ts){ const hr=new Date(r.ts).getHours(); byHour[hr]=(byHour[hr]||0)+recordNet(r)*frac; }
r.lines.filter(l=>!l.returned).forEach(l=>{ items += l.qty; cogs += itemCost(l)*l.qty; byCat[l.category||"other"]=(byCat[l.category||"other"]||0)+l.price*l.qty;
+ if(l.comped) comps += (l.comped.amount||0); // 🎁 value given away as comps today
const bi=byItem[l.name]=byItem[l.name]||{qty:0,rev:0,cost:0}; bi.qty+=l.qty; bi.rev+=l.price*l.qty; bi.cost+=itemCost(l)*l.qty; });
const ret = -((r.tenders||[]).filter(t=>t.refund).reduce((s,t)=>s+t.amount,0)); if(ret>0.0001) refunds += ret; // partial line returns
}
@@ -922,9 +975,18 @@
const sum=document.createElement("div"); sum.className="card";
sum.innerHTML="Sales summary
"
+ L("Orders", String(orders)) + L("Items sold", String(items))
- + L("Net sales", money(net)) + (disc?L("Discounts","−"+money(disc)):"") + L("Tax collected", money(tax)) + (tips?L("Tips", money(tips)):"") + (refunds?L("Refunds","−"+money(refunds)):"")
+ + L("Net sales", money(net)) + (disc?L("Discounts","−"+money(disc)):"") + (comps?L("Comps (on the house)","−"+money(comps)):"") + L("Tax collected", money(tax)) + (tips?L("Tips", money(tips)):"") + (refunds?L("Refunds","−"+money(refunds)):"")
+ L("Total collected", money(collected), true) + (onAccount?L("On account (A/R)", money(onAccount)):"");
app.appendChild(sum);
+ // 💸 tip-out by role — the payroll pain point. When configured, show each support role's cut of today's tips
+ // (a % of tips) and what's left for the servers, so tip-out is spelled out at close instead of done by hand.
+ const tipOut=(FLOW.endpoints.payment.tip&&FLOW.endpoints.payment.tip.tipOut)||null;
+ if(tipOut && tipOut.length && tips>0.0001){
+ let out=0; const rows=tipOut.map(t=>{ const cut=+(tips*(+t.pct||0)/100).toFixed(2); out+=cut; return L("→ "+(t.role||"support")+" ("+(+t.pct||0)+"%)","−"+money(cut)); }).join("");
+ const toCard=document.createElement("div"); toCard.className="card";
+ toCard.innerHTML="Tip-out from "+money(tips)+" in tips
"+rows+L("Kept by servers", money(+(tips-out).toFixed(2)), true);
+ app.appendChild(toCard);
+ }
// ---- community: charity round-ups collected today (only when the round-up option is on) ----
if(FLOW.endpoints.payment.roundUp && roundUp>0){ const cause=(FLOW.endpoints.payment.roundUp.cause)||"a local cause";
const cc=document.createElement("div"); cc.className="card"; cc.innerHTML="Community ♥
"+L("Rounded up for "+cause, money(roundUp), true)+"Collected from customers who chose to round up — set it aside to donate.
";
@@ -1115,7 +1177,9 @@
// ---- ID checks (audit): defensible record of every age-restricted sale that was verified ----
{ const idlog=(DB.ageLog||[]).filter(a=>a.day===todayStr());
if(idlog.length){ const ic=document.createElement("div"); ic.className="card"; ic.innerHTML="ID checks (audit) "+idlog.length+" today
";
- idlog.slice(-8).reverse().forEach(a=>{ const row=document.createElement("div"); row.className="line"; row.innerHTML=""+a.at+" · #"+a.number+" — verified "+a.req+"+"+(a.station||"")+""; ic.appendChild(row); }); app.appendChild(ic); } }
+ idlog.slice(-8).reverse().forEach(a=>{ const row=document.createElement("div"); row.className="line";
+ const res=a.result==="refused" ? ("REFUSED — under "+a.req+"") : ("verified "+a.req+"+");
+ row.innerHTML=""+a.at+" · #"+a.number+" — "+res+(a.age!=null?" (age "+a.age+")":"")+""+(a.station||"")+""; ic.appendChild(row); }); app.appendChild(ic); } }
// ---- commission (only when the trade pays commission) ----
const pct=(FLOW.commission&&FLOW.commission.pct)||0;
if(FLOW.performers){
@@ -1404,9 +1468,9 @@
if(!live.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="All caught up — no tickets working."; app.appendChild(e); }
live.forEach(r=>{ const c=document.createElement("div"); c.className="card";
const fc=r.firedCourse||1;
- c.innerHTML="#"+r.number+" "+kdsChip(r)+(coursingOn()?" "+courseLabel(fc)+"":"")+""+r.createdAt+"
";
+ c.innerHTML="#"+r.number+" "+kdsChip(r)+orderTypeChip(r)+channelChip(r)+(coursingOn()?" "+courseLabel(fc)+"":"")+""+r.createdAt+"
";
r.lines.filter(l=>courseReleased(r,l)).forEach(l=>{ const row=document.createElement("div"); row.className="line"; const where=lineComplete(l)?"ready":("at "+l.path[l.stage]);
- row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+""+where+""; c.appendChild(row); });
+ row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+""+where+""; c.appendChild(row); });
// held courses waiting to be fired
if(coursingOn()){ const held=r.lines.filter(l=>!courseReleased(r,l));
if(held.length){ const h=document.createElement("div"); h.className="muted"; h.style.marginTop="4px"; h.textContent="held: "+held.map(l=>l.qty+"× "+l.name).join(", ")+" ("+courseLabel(fc+1)+")"; c.appendChild(h); } }
@@ -1431,14 +1495,37 @@
];
function floorStates(){ return (FLOW.floor && FLOW.floor.states) || FLOOR_STATES; }
function floorStateById(id){ return floorStates().find(s=>s.id===id) || floorStates()[0]; }
-function tableRec(t){ const r=(DB.tables||{})[t.id]; return { state:(r&&r.state)||floorStates()[0].id, since:(r&&r.since)||null, seatedTs:(r&&r.seatedTs)||null }; }
+function tableRec(t){ const r=(DB.tables||{})[t.id]; return { state:(r&&r.state)||floorStates()[0].id, since:(r&&r.since)||null, seatedTs:(r&&r.seatedTs)||null, server:(r&&r.server)||null }; }
+// 🧑🍳 server assignment: which server owns a table, and the "my tables" view.
+function tableServer(id){ const r=(DB.tables||{})[id]; return (r&&r.server)||null; }
+function serverStaff(){ return (FLOW.staff||[]); } // any staff member can own tables
+function assignServer(id, name){ DB=loadDB(); DB.tables=DB.tables||{}; const prev=DB.tables[id]||{ state:floorStates()[0].id, since:Date.now() };
+ DB.tables[id]=Object.assign({}, prev, { server:(name||null) }); saveDB(DB);
+ logActivity("access", (name?("table "+tableLabel(id)+" → "+name):("cleared server on table "+tableLabel(id)))); render(); }
// state change also drives turn-time analytics: a "turn" runs from leaving Empty until returning to Empty
function setTableState(id, state){ DB=loadDB(); DB.tables=DB.tables||{}; DB.turns=DB.turns||[];
const emptyId=floorStates()[0].id; const now=Date.now(); const prev=DB.tables[id]||{}; let seatedTs=prev.seatedTs||null;
const wasEmpty=!prev.state || prev.state===emptyId;
if(wasEmpty && state!==emptyId) seatedTs=now; // a table just got seated — turn begins
if(state===emptyId){ if(seatedTs) DB.turns.push({ table:id, seatedTs, clearedTs:now, dur:now-seatedTs, day:todayStr() }); seatedTs=null; } // cleared — turn ends, logged
- DB.tables[id]={ state, since:now, seatedTs }; saveDB(DB); render();
+ DB.tables[id]=Object.assign({}, prev, { state, since:now, seatedTs }); // keep the assigned server across state changes
+ if(state===emptyId) delete DB.tables[id].server; // a cleared table is free again
+ saveDB(DB); render();
+}
+// 🔀 TRANSFER / MERGE tables — move a table's open check(s) to another table. If the target is empty it's a
+// transfer (party moved); if it already has a check it's a merge (two tables become one party). The source is
+// then freed (its turn ends). Reuses setTableState so turn analytics and the server-clear stay consistent.
+function moveTable(fromId, toId){ if(!toId || fromId===toId) return;
+ DB=loadDB(); const moved=(DB.records||[]).filter(r=>r.tableId===fromId && r.status!=="CLOSED" && r.status!=="REFUNDED");
+ if(!moved.length){ flash("No open check on "+tableLabel(fromId)); return; }
+ moved.forEach(r=>{ r.tableId=toId; r._t=Date.now(); });
+ DB.tables=DB.tables||{}; const src=DB.tables[fromId]||{}, tgt=DB.tables[toId]||{};
+ const emptyId=floorStates()[0].id, occ=(floorStates()[1]||floorStates()[0]).id;
+ const wasOccupied=(tgt.state && tgt.state!==emptyId);
+ DB.tables[toId]=Object.assign({}, tgt, { state: wasOccupied?tgt.state:occ, since:Date.now(), seatedTs: tgt.seatedTs||src.seatedTs||Date.now(), server: tgt.server||src.server||null });
+ saveDB(DB);
+ logActivity("access", (wasOccupied?"merged ":"moved ")+tableLabel(fromId)+" → "+tableLabel(toId)+" ("+moved.length+" check"+(moved.length>1?"s":"")+")");
+ selTable=toId; setTableState(fromId, emptyId); // free the source (logs its turn, clears its server, saves + renders)
}
function tableSeats(id){ const t=((FLOW.floor&&FLOW.floor.tables)||[]).find(x=>x.id===id); return (t&&+t.seats)||0; }
function tableLabel(id){ const t=((FLOW.floor&&FLOW.floor.tables)||[]).find(x=>x.id===id); return (t&&(t.label||t.id))||id; }
@@ -1447,15 +1534,69 @@
function tableOpenTotal(id){ return tableOpenOrders(id).reduce((s,r)=>s+recordBalance(r),0); }
function turnsToday(){ return (DB.turns||[]).filter(x=>x.day===todayStr()); }
function turnStats(){ const ts=turnsToday(); const n=ts.length; return { n, avgMs:n?ts.reduce((s,x)=>s+x.dur,0)/n:0, covers:ts.reduce((s,x)=>s+tableSeats(x.table),0) }; }
+/* 🕐 RESERVATIONS + WAITLIST — the host stand, run from the floor. Reservations are booked for a time today;
+ the waitlist holds walk-ins with a quoted wait. Both can be "seated" (optionally onto the selected table) or
+ cleared. Records are never hard-deleted — a reservation is cancelled, a waitlist party marked seated/left. */
+function todayReservations(){ return (DB.reservations||[]).filter(r=>r.day===todayStr() && r.status!=="cancelled").sort((a,b)=>String(a.time||"").localeCompare(String(b.time||""))); }
+function addReservation(name, party, time, phone){ name=(name||"").trim(); if(!name){ flash("Name for the reservation?"); return; }
+ DB=loadDB(); DB.reservations=DB.reservations||[]; DB.reservations.push({ id:"RSV"+Date.now(), name, phone:(phone||"").trim(), party:Math.max(1,+party||1), time:(time||"").trim(), day:todayStr(), status:"booked" });
+ logActivity("access","reservation · "+name+" ×"+(Math.max(1,+party||1))+((time||"").trim()?" @ "+time.trim():"")); saveDB(DB); render(); }
+function seatReservation(id, tableId){ DB=loadDB(); const r=(DB.reservations||[]).find(x=>x.id===id); if(!r) return; r.status="seated"; if(tableId) r.tableId=tableId; logActivity("access","seated reservation · "+r.name); saveDB(DB);
+ if(tableId) setTableState(tableId, (floorStates()[1]||floorStates()[0]).id); else render(); } // save the seated status BEFORE setTableState (it reloads DB)
+function cancelReservation(id){ DB=loadDB(); const r=(DB.reservations||[]).find(x=>x.id===id); if(!r) return; r.status="cancelled"; logActivity("access","cancelled reservation · "+r.name); saveDB(DB); render(); }
+function activeWaitlist(){ return (DB.waitlist||[]).filter(w=>w.status==="waiting").sort((a,b)=>(a.addedTs||0)-(b.addedTs||0)); }
+function waitlistWait(w){ return Math.round((Date.now()-(w.addedTs||Date.now()))/60000); }
+function addWaitlist(name, party, quote, phone){ name=(name||"").trim(); if(!name){ flash("Name for the waitlist?"); return; }
+ DB=loadDB(); DB.waitlist=DB.waitlist||[]; DB.waitlist.push({ id:"WL"+Date.now(), name, phone:(phone||"").trim(), party:Math.max(1,+party||1), quote:Math.max(0,+quote||0), addedTs:Date.now(), status:"waiting" });
+ logActivity("access","waitlist + "+name+" ×"+(Math.max(1,+party||1))+((+quote||0)?" ~"+(+quote)+"m":"")); saveDB(DB); render(); }
+function seatWaitlist(id, tableId){ DB=loadDB(); const w=(DB.waitlist||[]).find(x=>x.id===id); if(!w) return; w.status="seated"; if(tableId) w.tableId=tableId; logActivity("access","seated waitlist · "+w.name); saveDB(DB);
+ if(tableId) setTableState(tableId, (floorStates()[1]||floorStates()[0]).id); else render(); } // save before setTableState (it reloads DB)
+function leaveWaitlist(id){ DB=loadDB(); const w=(DB.waitlist||[]).find(x=>x.id===id); if(!w) return; w.status="left"; logActivity("access","waitlist left · "+w.name); saveDB(DB); render(); }
+// the host stand UI — a Reservations card and a Waitlist card, each with a quick add row + "Seat"/"clear".
+// "Seat" uses the currently-selected table when one is picked, so the host taps a table then seats the party.
+function renderHostStand(app){
+ const seatTarget = selTable ? (" → "+tableLabel(selTable)) : "";
+ const mkAdd=(fields, onAdd)=>{ const row=document.createElement("div"); row.className="opts"; row.style.cssText="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px";
+ const els={}; fields.forEach(f=>{ const i=document.createElement("input"); i.placeholder=f.ph; i.id=f.id; if(f.w) i.style.width=f.w; if(f.type) i.type=f.type; row.appendChild(i); els[f.id]=i; });
+ const add=document.createElement("button"); add.className="primary"; add.textContent="Add"; add.onclick=()=>{ onAdd(els); }; row.appendChild(add); return row; };
+ // Reservations
+ const rc=document.createElement("div"); rc.className="card"; rc.style.marginBottom="10px";
+ rc.innerHTML="🕐 Reservations "+todayReservations().filter(r=>r.status==="booked").length+" booked
";
+ todayReservations().forEach(r=>{ const row=document.createElement("div"); row.className="line";
+ row.innerHTML=""+(r.status==="seated"?"✓ ":"")+""+(r.time||"—")+" · "+r.name+" ×"+r.party+(r.phone?" "+r.phone+"":"")+(r.status==="seated"?" seated":"")+"";
+ if(r.status!=="seated"){ const s=document.createElement("button"); s.className="good"; s.textContent="Seat"+seatTarget; s.onclick=()=>seatReservation(r.id, selTable||null); row.appendChild(s);
+ const x=document.createElement("button"); x.textContent="✕"; x.title="Cancel"; x.style.marginLeft="4px"; x.onclick=()=>cancelReservation(r.id); row.appendChild(x); }
+ rc.appendChild(row); });
+ rc.appendChild(mkAdd([{id:"rsvName",ph:"name"},{id:"rsvParty",ph:"party",w:"64px",type:"number"},{id:"rsvTime",ph:"time e.g. 7:00",w:"110px"},{id:"rsvPhone",ph:"phone (optional)"}],
+ els=>{ addReservation(els.rsvName.value, els.rsvParty.value, els.rsvTime.value, els.rsvPhone.value); }));
+ app.appendChild(rc);
+ // Waitlist
+ const wc=document.createElement("div"); wc.className="card"; wc.style.marginBottom="10px";
+ wc.innerHTML="📝 Waitlist "+activeWaitlist().length+" waiting
";
+ activeWaitlist().forEach(w=>{ const row=document.createElement("div"); row.className="line"; const waited=waitlistWait(w);
+ row.innerHTML=""+w.name+" ×"+w.party+(w.quote?" · quoted "+w.quote+"m":"")+" · waiting "+waited+"m";
+ const s=document.createElement("button"); s.className="good"; s.textContent="Seat"+seatTarget; s.onclick=()=>seatWaitlist(w.id, selTable||null); row.appendChild(s);
+ const x=document.createElement("button"); x.textContent="✕"; x.title="Left"; x.style.marginLeft="4px"; x.onclick=()=>leaveWaitlist(w.id); row.appendChild(x);
+ wc.appendChild(row); });
+ wc.appendChild(mkAdd([{id:"wlName",ph:"name"},{id:"wlParty",ph:"party",w:"64px",type:"number"},{id:"wlQuote",ph:"quote min",w:"90px",type:"number"},{id:"wlPhone",ph:"phone (optional)"}],
+ els=>{ addWaitlist(els.wlName.value, els.wlParty.value, els.wlQuote.value, els.wlPhone.value); }));
+ app.appendChild(wc);
+}
function renderFloor(app, station){
const cfg=FLOW.floor||{ tables:[] };
app.innerHTML=""+station.label+"
";
- const tables=(cfg.tables||[]).filter(t=> !floorSection || (t.section||"")===floorSection);
+ renderHostStand(app); // 🕐 reservations + waitlist at the top of the host's floor screen
+ const tables=(cfg.tables||[]).filter(t=> (!floorSection || (t.section||"")===floorSection) && (!floorServer || tableServer(t.id)===floorServer));
const sections=cfg.sections || [...new Set((cfg.tables||[]).map(t=>t.section).filter(Boolean))];
// section filter (server sees a section; manager sees all)
if(sections.length){ const bar=document.createElement("div"); bar.className="opts"; bar.style.marginBottom="10px";
const mk=(lab,val)=>{ const b=document.createElement("button"); if(floorSection===val) b.className="sel"; b.textContent=lab; b.onclick=()=>{ floorSection=val; selTable=null; render(); }; bar.appendChild(b); };
mk("All", null); sections.forEach(s=>mk(s, s)); app.appendChild(bar); }
+ // 🧑🍳 "my tables" — filter the floor to one server's tables
+ if(serverStaff().length){ const sbar=document.createElement("div"); sbar.className="opts"; sbar.style.marginBottom="10px";
+ const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="6px"; lbl.textContent="Tables:"; sbar.appendChild(lbl);
+ const mk=(lab,val)=>{ const b=document.createElement("button"); if(floorServer===val) b.className="sel"; b.textContent=lab; b.onclick=()=>{ floorServer=val; selTable=null; render(); }; sbar.appendChild(b); };
+ mk("All", null); serverStaff().forEach(s=>mk(s.name, s.name)); app.appendChild(sbar); }
// at-a-glance: how many tables are occupied (not empty)
const occupied=tables.filter(t=>tableRec(t).state!=="empty").length;
const meta=document.createElement("p"); meta.className="muted"; meta.style.margin="0 0 10px"; meta.textContent=occupied+" of "+tables.length+" tables occupied · tap a table to advance it"; app.appendChild(meta);
@@ -1478,6 +1619,7 @@
const chk=tableOpenTotal(t.id);
b.innerHTML=""+ (t.label||t.id) +" "+st.label+""
+""+(t.seats?t.seats+" seats":"")+(t.section?" · "+t.section:"")+""
+ +""+(tableServer(t.id)?"🧑🍳 "+tableServer(t.id):"")+""
+""+(chk>0.0001?"🧾 "+money(chk)+" open":(tr.since?fmtDur(el)+" in state":"—"))+"";
b.onclick=()=>{ selTable=(selTable===t.id?null:t.id); render(); };
grid.appendChild(b); });
@@ -1496,7 +1638,19 @@
if(nx){ const adv=document.createElement("button"); adv.className="primary"; adv.textContent="→ "+nx.label; adv.style.margin="4px 8px 8px 0"; adv.onclick=()=>{ setTableState(t.id, nx.id); }; c.appendChild(adv); }
const jump=document.createElement("div"); jump.className="opts";
floorStates().forEach(s=>{ const b=document.createElement("button"); if(s.id===st.id) b.className="sel"; b.textContent=s.label; b.onclick=()=>setTableState(t.id, s.id); jump.appendChild(b); });
- c.appendChild(jump); app.appendChild(c);
+ c.appendChild(jump);
+ // 🧑🍳 assign a server to this table (or clear)
+ if(serverStaff().length){ const sv=document.createElement("div"); sv.className="opts"; sv.style.marginTop="8px";
+ const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="6px"; lbl.textContent="Server:"; sv.appendChild(lbl);
+ serverStaff().forEach(s=>{ const b=document.createElement("button"); if(tableServer(t.id)===s.name) b.className="sel"; b.textContent=s.name; b.onclick=()=>assignServer(t.id, tableServer(t.id)===s.name?null:s.name); sv.appendChild(b); });
+ c.appendChild(sv); }
+ // 🔀 transfer / merge this table's open check to another table
+ if(tableOpenOrders(t.id).length){ const mv=document.createElement("div"); mv.className="opts"; mv.style.marginTop="8px";
+ const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="6px"; lbl.textContent="Move check to:"; mv.appendChild(lbl);
+ (cfg.tables||[]).filter(x=>x.id!==t.id).forEach(x=>{ const occ=tableOpenOrders(x.id).length; const b=document.createElement("button");
+ b.textContent=(x.label||x.id)+(occ?" (merge)":""); b.title=occ?("Merge onto "+(x.label||x.id)):("Move to "+(x.label||x.id)); b.onclick=()=>moveTable(t.id, x.id); mv.appendChild(b); });
+ c.appendChild(mv); }
+ app.appendChild(c);
}
}
}
@@ -1827,6 +1981,7 @@
let reportStore = null; // multi-store report: which store the report is scoped to (null = all stores, with roll-up)
let selTable = null; // floor plan: the table currently opened
let floorSection = null; // floor plan: active section filter (null = all)
+let floorServer = null; // floor plan: "my tables" — filter to one server (null = all)
let selCell = null; // scheduling: the {staffId, day} cell being edited
function attachCustomer(phone, name){
phone=(phone||"").trim(); if(!phone) return;
@@ -1875,38 +2030,86 @@
function renderIntake(app, station){
if(!draft) draft = { lines:[] };
renderCustomerBar(app, station);
+ // 🚗 order type — dine-in / to-go / drive-thru (when the flow configures them)
+ if(orderTypes() && orderTypes().length){
+ if(!draft.orderType) draft.orderType=orderTypes()[0];
+ const ot=document.createElement("div"); ot.className="card"; ot.style.cssText="padding:8px 10px;margin-bottom:10px";
+ const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="8px"; lbl.textContent="Order:"; ot.appendChild(lbl);
+ const opts=document.createElement("div"); opts.className="opts"; opts.style.display="inline-flex"; opts.style.flexWrap="wrap";
+ orderTypes().forEach(tp=>{ const b=document.createElement("button"); if(draft.orderType===tp) b.className="sel"; b.textContent=tp; b.onclick=()=>{ draft.orderType=tp; render(); }; opts.appendChild(b); });
+ ot.appendChild(opts); app.appendChild(ot);
+ }
// table selector — when a floor is configured, ring this check to a table (restaurants)
if(FLOW.floor && (FLOW.floor.tables||[]).length){
const tb=document.createElement("div"); tb.className="card"; tb.style.cssText="padding:8px 10px;margin-bottom:10px";
const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="8px"; lbl.textContent="🍽 Table:"; tb.appendChild(lbl);
const opts=document.createElement("div"); opts.className="opts"; opts.style.display="inline-flex"; opts.style.flexWrap="wrap";
(FLOW.floor.tables||[]).forEach(t=>{ const b=document.createElement("button"); const open=tableOpenTotal(t.id); if(draft.tableId===t.id) b.className="sel";
- b.textContent=(t.label||t.id)+(open>0.0001?" · "+money(open):""); b.onclick=()=>{ draft.tableId=(draft.tableId===t.id?null:t.id); render(); }; opts.appendChild(b); });
- const none=document.createElement("button"); if(!draft.tableId) none.className="sel"; none.textContent="No table"; none.onclick=()=>{ draft.tableId=null; render(); }; opts.appendChild(none);
+ b.textContent=(t.label||t.id)+(open>0.0001?" · "+money(open):""); b.onclick=()=>{ draft.tableId=(draft.tableId===t.id?null:t.id); draft.seat=null; draft.__seatMax=0; render(); }; opts.appendChild(b); });
+ const none=document.createElement("button"); if(!draft.tableId) none.className="sel"; none.textContent="No table"; none.onclick=()=>{ draft.tableId=null; draft.seat=null; draft.__seatMax=0; render(); }; opts.appendChild(none);
tb.appendChild(opts); app.appendChild(tb);
}
+ // 👤 seat picker — dine-in seat-by-seat ordering. Pick a seat and the items you tap ring to that seat (so the
+ // kitchen and the check know whose is whose, and the table can split by seat). "Whole table" = shared/no seat.
+ // Seats shown = the table's capacity, or higher if the ticket already used more, or the server added seats.
+ if(draft.tableId){
+ const cap=tableSeats(draft.tableId)||0;
+ const usedMax=Math.max(0, ...(draft.lines||[]).map(l=>+l.seat||0));
+ const shown=Math.max(cap, usedMax, draft.__seatMax||0, 4); // always offer at least a few
+ const sc=document.createElement("div"); sc.className="card"; sc.style.cssText="padding:8px 10px;margin-bottom:10px";
+ const slbl=document.createElement("span"); slbl.className="muted"; slbl.style.marginRight="8px"; slbl.textContent="👤 Seat:"; sc.appendChild(slbl);
+ const sopts=document.createElement("div"); sopts.className="opts"; sopts.style.display="inline-flex"; sopts.style.flexWrap="wrap";
+ const whole=document.createElement("button"); if(!draft.seat) whole.className="sel"; whole.textContent="Whole table"; whole.onclick=()=>{ draft.seat=null; render(); }; sopts.appendChild(whole);
+ for(let n=1;n<=shown;n++){ const cnt=(draft.lines||[]).filter(l=>(+l.seat||0)===n).length; const b=document.createElement("button"); if(draft.seat===n) b.className="sel";
+ b.textContent="Seat "+n+(cnt?" ("+cnt+")":""); b.onclick=()=>{ draft.seat=n; render(); }; sopts.appendChild(b); }
+ const add=document.createElement("button"); add.textContent="+ seat"; add.title="Add another seat"; add.onclick=()=>{ draft.__seatMax=shown+1; draft.seat=shown+1; render(); }; sopts.appendChild(add);
+ sc.appendChild(sopts); app.appendChild(sc);
+ }
const cat=document.createElement("div"); cat.innerHTML="New order
";
// search / barcode-scan box — shown for big menus or when a scanner is configured (keyboard-wedge types code + Enter)
const tiles=[];
const addItem=item=>{ const it=priceItem(item); if(!it) return;
return needsConfig(it) ? (pending={item:it, choices:{}, addons:[], flags:[], performer:null}, render()) : addLine(it, {}, [], [], null); };
+ // 🍔 Toast-style category tabs: a long menu (a real restaurant) is unusable as one flat wall of tiles, so we
+ // tab it by category. Generic to every vertical — categories come from the catalog in first-appearance order,
+ // labels from an optional FLOW.catLabels map (else the category title-cased). The dry cleaner's short menu
+ // stays a plain grid. Search + category filter combine through one applyFilter so they never fight.
+ let activeCat = draft.__cat || "__all";
+ const catLabel = c => (FLOW.catLabels && FLOW.catLabels[c]) || (c ? c.charAt(0).toUpperCase()+c.slice(1) : "Other");
+ const applyFilter=()=>{ const s=document.getElementById("catSearch"); const q=s?s.value.trim().toLowerCase():"";
+ tiles.forEach(({item,btn})=>{ const inCat=(activeCat==="__all")||((item.category||"other")===activeCat);
+ const hay=(item.name+" "+(item.category||"")+" "+(item.barcode||item.sku||"")).toLowerCase();
+ btn.style.display=(inCat && (!q||hay.includes(q)))?"":"none"; }); };
if(FLOW.catalog.length>5 || FLOW.endpoints.scan){
const search=document.createElement("input"); search.id="catSearch"; search.placeholder="Search or scan…"; search.autocomplete="off"; search.style.cssText="width:100%;margin-bottom:10px";
- search.oninput=()=>{ const q=search.value.trim().toLowerCase(); tiles.forEach(({item,btn})=>{ const hay=(item.name+" "+(item.category||"")+" "+(item.barcode||item.sku||"")).toLowerCase(); btn.style.display=(!q||hay.includes(q))?"":"none"; }); };
+ search.oninput=applyFilter;
search.onkeydown=e=>{ if(e.key!=="Enter") return; const q=search.value.trim().toLowerCase(); if(!q) return;
const exact=tiles.find(({item})=>String(item.barcode||item.sku||"").toLowerCase()===q && !item.__out);
const vis=tiles.find(({btn,item})=>btn.style.display!=="none" && !item.__out);
const hit=exact||vis; if(hit){ addItem(hit.item); } else { flash("No match for “"+search.value.trim()+"”"); } };
cat.appendChild(search);
}
+ const catsInOrder=[]; FLOW.catalog.forEach(it=>{ const c=it.category||"other"; if(catsInOrder.indexOf(c)<0) catsInOrder.push(c); });
+ if(FLOW.catalog.length>8 && catsInOrder.length>1){
+ const tabs=document.createElement("div"); tabs.className="opts cattabs"; tabs.style.cssText="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px";
+ const mk=(key,label)=>{ const t=document.createElement("button"); t.textContent=label; if(activeCat===key) t.className="sel";
+ t.onclick=()=>{ activeCat=key; draft.__cat=key; tabs.querySelectorAll("button").forEach(x=>x.classList.remove("sel")); t.classList.add("sel"); applyFilter(); };
+ tabs.appendChild(t); };
+ mk("__all","All"); catsInOrder.forEach(c=>mk(c,catLabel(c)));
+ cat.appendChild(tabs);
+ }
const grid=document.createElement("div"); grid.className="grid";
FLOW.catalog.forEach(item=>{
- const b=document.createElement("button"); b.className="tile"; const rem=remainingOf(item); const out=is86(item)||isOutOfStock(item);
+ const b=document.createElement("button"); b.className="tile"; const rem=remainingOf(item); const offHours=!itemAvailNow(item); const out=is86(item)||isOutOfStock(item)||offHours;
item.__out=out;
if(out) b.className="tile eightysix";
const via=item.path.length?item.path.join(" → "):"no prep";
- const priceLbl = item.customPrice ? "price at ring" : money(item.price)+(item.byWeight?" / "+(item.unit||"lb"):"");
+ const happy = item.happyPrice!=null && happyActive();
+ const priceLbl = item.customPrice ? "price at ring"
+ : (happy ? (""+money(item.price)+" "+money(item.happyPrice)+" ⏰ happy hour")
+ : money(item.price)+(item.byWeight?" / "+(item.unit||"lb"):""));
let sub = station.view.money?priceLbl+" · "+via:via;
+ if(offHours) sub += " · "+(item.avail&&(item.avail.from||item.avail.to)?((item.avail.from||"")+"–"+(item.avail.to||"")):"off hours")+"";
if(item.par!=null) sub += is86(item) ? " · 86" : " · "+rem+" left";
if(tracksStock(item)) sub += isOutOfStock(item) ? " · out of stock" : " · "+stockOnHand(item)+" in stock"+(isLowStock(item)?" low":"");
b.innerHTML = item.name + ""+sub+"";
@@ -1916,13 +2119,14 @@
grid.appendChild(b);
});
cat.appendChild(grid); app.appendChild(cat);
+ applyFilter(); // honor the active category tab (and any typed search) on first paint
if(pending) app.appendChild(configPanel(station));
const c=document.createElement("div"); c.className="card";
if(!draft.lines.length){ c.innerHTML="Tap items to build the order.
"; }
else { draft.lines.forEach((l,idx)=>{ const row=document.createElement("div"); row.className="line";
- row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+(l.performer?" "+l.performer+"":"")+tagChip(l)+""+((l.path||[]).join(' → ')||'—')+""+(station.view.money?""+money(l.price*l.qty)+"":"");
+ row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+(l.performer?" "+l.performer+"":"")+tagChip(l)+""+((l.path||[]).join(' → ')||'—')+""+(station.view.money?""+money(l.price*l.qty)+"":"");
if(station.view.money){ // quantity steppers + per-line price edit (haggling, custom items, ring 6 in one tap)
const dec=document.createElement("button"); dec.textContent="−"; dec.title="one fewer"; dec.style.cssText="padding:2px 9px;margin-left:6px"; dec.onclick=()=>{ if(l.qty>1) l.qty--; else { logActivity("void", "removed "+l.name+" ("+money(l.price)+") from an unsent ticket", null, l.price); draft.lines.splice(idx,1); } render(); }; row.appendChild(dec);
const inc=document.createElement("button"); inc.textContent="+"; inc.title="one more"; inc.style.cssText="padding:2px 9px;margin-left:4px"; inc.onclick=()=>{ l.qty++; render(); }; row.appendChild(inc);
@@ -1932,6 +2136,17 @@
c.appendChild(row); });
const tot=document.createElement("div"); tot.className="line"; tot.innerHTML="Total"+money(draft.lines.reduce((s,l)=>s+l.price*l.qty,0))+""; c.appendChild(tot); }
app.appendChild(c);
+ // 💡 suggested add-ons (upsell): items referenced by draft items' `suggest`, not already on the ticket — one tap adds
+ if(draft.lines.length && station.view.money){
+ const want={}; draft.lines.forEach(l=>{ const it=FLOW.catalog.find(x=>x.id===l.catId); (it&&it.suggest||[]).forEach(id=>{ want[id]=1; }); });
+ const have={}; draft.lines.forEach(l=>{ have[l.catId]=1; });
+ const sugg=Object.keys(want).map(id=>FLOW.catalog.find(x=>x.id===id)).filter(it=>it && !have[it.id] && !is86(it) && !isOutOfStock(it) && itemAvailNow(it));
+ if(sugg.length){ const sc=document.createElement("div"); sc.className="card"; sc.style.cssText="padding:8px 10px";
+ const lbl=document.createElement("div"); lbl.className="lbl"; lbl.textContent="💡 Want to add…"; sc.appendChild(lbl);
+ const opts=document.createElement("div"); opts.className="opts"; opts.style.cssText="display:flex;flex-wrap:wrap;gap:6px";
+ sugg.forEach(it=>{ const b=document.createElement("button"); b.textContent="+ "+it.name+" "+money(itemPriceNow(it)); b.onclick=()=>addItem(it); opts.appendChild(b); });
+ sc.appendChild(opts); app.appendChild(sc); }
+ }
// deposit control (deposit-balance payment point)
const dep = FLOW.endpoints.payment.deposit;
if(dep && station.view.money && draft.lines.length){
@@ -1971,10 +2186,18 @@
}
}
+// 🔀 conditional (nested) modifiers: a modifier option or an add-on can carry a `reveal` group that only appears
+// once it's chosen (e.g. "Add cheese" → "Which cheese?"). Groups expand across the list, so reveals can nest.
+function revealedGroups(item){
+ const out=[...(item.modifiers||[])];
+ (item.addons||[]).forEach(a=>{ if(a.reveal && pending.addons.some(x=>x.name===a.name) && out.indexOf(a.reveal)<0) out.push(a.reveal); });
+ for(let i=0;i";
- (item.modifiers||[]).forEach((g,gi)=>{
+ revealedGroups(item).forEach((g,gi)=>{
const wrap=document.createElement("div"); wrap.className="mgroup";
wrap.innerHTML=""+g.group+(g.required?" (required)":"")+"
";
const opts=document.createElement("div"); opts.className="opts";
@@ -2002,9 +2225,11 @@
(FLOW.performers||[]).forEach(name=>{ const b=document.createElement("button"); if(pending.performer===name) b.className="sel"; b.textContent=name; b.onclick=()=>{ pending.performer=name; render(); }; opts.appendChild(b); });
wrap.appendChild(opts); c.appendChild(wrap);
}
- const reqMet=(item.modifiers||[]).every(g=>!g.required||pending.choices[g.group]) && (!item.performer || pending.performer);
+ const active=revealedGroups(item);
+ const reqMet=active.every(g=>!g.required||pending.choices[g.group]) && (!item.performer || pending.performer);
const add=document.createElement("button"); add.className="primary"; add.textContent="Add to order"; add.disabled=!reqMet; add.style.marginRight="8px";
- add.onclick=()=>{ addLine(item, pending.choices, pending.addons, pending.flags, pending.performer); pending=null; render(); };
+ add.onclick=()=>{ const choices={}; active.forEach(g=>{ if(pending.choices[g.group]) choices[g.group]=pending.choices[g.group]; }); // only choices for groups still shown (a deselected reveal drops its choice)
+ addLine(item, choices, pending.addons, pending.flags, pending.performer); pending=null; render(); };
const cancel=document.createElement("button"); cancel.textContent="Cancel"; cancel.onclick=()=>{ pending=null; render(); };
c.appendChild(add); c.appendChild(cancel);
return c;
@@ -2024,6 +2249,7 @@
const modLabel=mods.map(m=>m.name).filter(Boolean).join(", ");
const cart = editingId ? ((DB.records||[]).find(r=>r.id===editingId)||draft) : draft; // detailing edits the order; intake edits the draft
cart.lines.push({ id:"L"+Math.round(performance.now())+Math.floor(Math.random()*1e4), catId:item.id, name:item.name, modLabel, flags:(flags||[]).slice(), performer:performer||null, price, qty:1, category:item.category, path:item.path.slice(), stage:0, course:item.course||1,
+ seat: (editingId ? null : (draft && draft.seat) || null), // 👤 dine-in: which seat this line rings to (null = whole table)
serialized: !!item.serialized, tag: item.serialized ? genUnitTag() : undefined, tagLabel: item.serialized ? (item.tagLabel||"Tag") : undefined });
if(editingId) saveDB(DB);
render();
@@ -2048,11 +2274,15 @@
if(isNaN(n)||n<0){ flash("Enter a price"); return null; }
return {...item, price:+n.toFixed(2)};
}
+ // ⏰ happy-hour: ring the happy price when the window is live (a fixed-price item)
+ const now=itemPriceNow(item); if(now!==item.price) return {...item, price:now};
return item;
}
function sendDraft(){
DB=loadDB(); DB.seq++;
const rec={ id:"R"+DB.seq, number:DB.seq, lines:draft.lines.map(l=>({...l, stage:0})), tenders:[], createdAt:new Date().toISOString().slice(11,16), ts:Date.now() };
+ if(draft.orderType) rec.orderType=draft.orderType; // 🚗 dine-in / to-go / drive-thru
+ if(draft.channel) rec.channel=draft.channel; // 🌐 in-house vs an online/delivery order handed in by the hub
if(storesOn()) rec.storeId = storeForOrder(draft.customer); // order belongs to a store (customer's, else this device's, else plant)
if(draft.tableId){ rec.tableId = draft.tableId; // ring the check to its table; nudge an empty table to "occupied"
DB.tables=DB.tables||{}; const cur=DB.tables[draft.tableId]; if(!cur || cur.state==="empty" || !cur.state){ DB.tables[draft.tableId]={ state:"ordered", since:Date.now(), seatedTs:(cur&&cur.seatedTs)||Date.now() }; } }
@@ -2116,13 +2346,13 @@
const recs=DB.records.filter(r=> r.status==="INPROGRESS" && linesAtStation(r, station.id).length);
if(!recs.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="Nothing waiting."; app.appendChild(e); }
recs.forEach(r=>{ const mine=linesAtStation(r, station.id); const c=document.createElement("div"); c.className="card";
- c.innerHTML="#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+""+r.createdAt+"
";
+ c.innerHTML="#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+orderTypeChip(r)+channelChip(r)+""+r.createdAt+"
";
let hasFlags=false, reconOk=true;
mine.forEach(l=>{ const row=document.createElement("div"); row.className="line"; const next=l.path[l.stage+1];
const flagHtml=(l.flags||[]).map(f=>{ hasFlags=true; return " ⚠ "+f+""; }).join("");
const perfHtml=l.performer?" "+l.performer+"":"";
if(station.reconcile){ const ck=reconChecked(r,station,l); if(!ck) reconOk=false; const cb=document.createElement("button"); cb.textContent=ck?"✓":"○"; cb.title="Count this piece out"; cb.style.cssText="padding:2px 9px;margin-right:4px"; if(ck) cb.className="sel"; cb.onclick=()=>reconToggle(r,station,l); row.appendChild(cb); }
- row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+perfHtml+flagHtml+tagChip(l)+bagChip(l)+""+(next?"→ "+next:"→ ready")+""); c.appendChild(row); });
+ row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+perfHtml+flagHtml+tagChip(l)+bagChip(l)+""+(next?"→ "+next:"→ ready")+""); c.appendChild(row); });
if(station.bag){ const bagged=mine.some(l=>l.bag); const bw=document.createElement("div"); bw.className="mgroup"; bw.innerHTML="Assembly bags
";
const bb=document.createElement("button"); bb.textContent=bagged?("🧺 Re-bag ("+((r.bagsAt&&r.bagsAt[station.id])||0)+" bag(s))"):("🧺 Bag these "+pieceCount(mine)+" piece(s)"); bb.onclick=()=>bagOrder(r,station); bw.appendChild(bb); c.appendChild(bw); }
if(printOn()){ const pw=document.createElement("div"); pw.className="mgroup"; pw.innerHTML="Print
";
@@ -2172,7 +2402,7 @@
const c=document.createElement("div"); c.className="card";
if(!rec.lines.length){ c.innerHTML="No pieces yet — tap items above.
"; }
else rec.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line";
- row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+tagChip(l)+""+(station.view.money?""+money(l.price*l.qty)+"":""));
+ row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+tagChip(l)+""+(station.view.money?""+money(l.price*l.qty)+"":""));
if(l.serialized){ const tb=document.createElement("button"); tb.textContent="🏷 "+(l.tag||"tag"); tb.title="Scan / set the "+(l.tagLabel||"tag"); tb.style.cssText="padding:2px 8px;margin-left:6px"; tb.onclick=()=>{ const v=prompt("Scan or type the "+(l.tagLabel||"tag")+" for this "+l.name+":", l.tag||""); if(v!=null&&v.trim()){ l.tag=v.trim(); saveDB(DB); render(); } }; row.appendChild(tb); }
const rm=document.createElement("button"); rm.textContent="✕"; rm.title="Remove"; rm.style.cssText="padding:2px 8px;margin-left:6px"; rm.onclick=()=>{ rec.lines=rec.lines.filter(x=>x.id!==l.id); saveDB(DB); render(); }; row.appendChild(rm);
c.appendChild(row); });
@@ -2203,11 +2433,12 @@
return job;
}
function dueDateText(r){ const t=FLOW.timer; if(!t||t.mode!=="due"||!r.ts) return ""; return new Date(r.ts + t.promiseSec*1000).toISOString().slice(0,16).replace("T"," "); }
-function docLineText(l){ return " "+(l.qty||1)+"x "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+(l.bag?" bag "+l.bag:""); }
+function docLineText(l){ return " "+(l.qty||1)+"x "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.seat?" [Seat "+l.seat+"]":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+(l.bag?" bag "+l.bag:""); }
function docTicket(r){
const L=[]; L.push(((FLOW.branding&&FLOW.branding.name)||"Order").toUpperCase());
if(storesOn()&&r.storeId){ L.push(storeName(r.storeId)); const aa=assembledAt(r); if(aa) L.push(aa); }
L.push("Order #"+r.number+" "+(r.createdAt||"")); if(r.customer) L.push((r.customer.name||"")+" "+(r.customer.phone||""));
+ if(r.orderType||r.tableId||(r.channel&&r.channel!=="in-house")) L.push([r.orderType, r.tableId?("Table "+tableLabel(r.tableId)):"", (r.channel&&r.channel!=="in-house")?r.channel:""].filter(Boolean).join(" "));
L.push("------------------------------"); (r.lines||[]).forEach(l=>L.push(docLineText(l)));
L.push("------------------------------"); const dd=dueDateText(r); if(dd) L.push("Promised: "+dd);
return L.join("\n");
@@ -2226,7 +2457,13 @@
function receiptPanel(r){
const sub=lineSubtotal(r), disc=recordDiscount(r), tax=recordTax(r), tip=r.tip||0, tot=recordTotal(r);
const line=(l,v,b)=>""+(b?"":"")+l+(b?"":"")+""+(b?"":"")+v+(b?"":"")+"
";
- const rows=r.lines.map(l=>""+l.qty+"× "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+""+money(l.price*l.qty)+"
").join("");
+ const lineRow=l=>""+l.qty+"× "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+""+money(l.price*l.qty)+"
";
+ // 👤 per-seat receipt: when the order was rung by seat, group the itemized lines under each seat with its subtotal
+ let rows;
+ if(orderHasSeats(r)){
+ rows=seatBuckets(r).map(bkt=>{ const its=r.lines.filter(l=>((l.seat?("s"+l.seat):"shared"))===bkt.key);
+ return ""+bkt.label+""+money(bkt.sub)+"
"+its.map(lineRow).join(""); }).join("");
+ } else { rows=r.lines.map(lineRow).join(""); }
const hr="
";
const totals=(disc||tax?line("Subtotal",money(sub)):"")+(disc?line("Discount","−"+money(disc)):"")+(tax?line(taxIncluded()?"Tax (incl.)":"Tax",money(tax)):"")+(tip?line("Tip",money(tip)):"")+line("Total",money(tot),true);
const tenders=(r.tenders||[]).map(t=>line(t.type==="card"?cardLabel(t):t.type[0].toUpperCase()+t.type.slice(1), money(t.amount))).join("");
@@ -2241,8 +2478,14 @@
const recs=DB.records.filter(r=> r.status==="READY"||r.status==="PAID");
if(!recs.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="No orders ready to check out."; app.appendChild(e); }
recs.forEach(r=>{ const c=document.createElement("div"); c.className="card"; const bal=recordBalance(r);
- c.innerHTML="#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+""+r.createdAt+"
";
- r.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+""+money(l.price*l.qty)+""; c.appendChild(row); });
+ c.innerHTML="#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+orderTypeChip(r)+channelChip(r)+""+r.createdAt+"
";
+ r.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line";
+ const priceHtml=l.comped ? (""+money((l.comped.wasPrice||0)*l.qty)+" "+money(0)) : money(l.price*l.qty);
+ row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+compChip(l)+""+priceHtml+"";
+ if(r.status==="READY" && station.view.money && !l.returned){ const cbtn=document.createElement("button"); cbtn.textContent=l.comped?"un-comp":"comp"; cbtn.title=l.comped?"Undo the comp":"Comp this item (on the house)"; cbtn.style.cssText="padding:2px 8px;margin-left:6px"; cbtn.onclick=()=>compLine(r,l); row.appendChild(cbtn);
+ if(r.tableId || orderHasSeats(r)){ const sbtn=document.createElement("button"); sbtn.textContent="seat"; sbtn.title="Move this item to another seat"; sbtn.style.cssText="padding:2px 8px;margin-left:4px";
+ sbtn.onclick=()=>{ const v=prompt("Move "+l.name+" to which seat? (number, or blank for whole table)", l.seat||""); if(v===null) return; const n=parseInt(v,10); l.seat=(v.trim()===""?null:(n>0?n:l.seat)); saveDB(DB); logActivity("access","moved "+l.name+" → "+(l.seat?("seat "+l.seat):"whole table")+" on #"+r.number, r.number); render(); }; row.appendChild(sbtn); } }
+ c.appendChild(row); });
// tax area (destination sales tax): for delivery, tax at the delivery address's rate, not the shop's.
// Shown only when tax zones are configured; "Shop" = the base (origin) rate.
if(FLOW.endpoints.tax && Array.isArray(FLOW.endpoints.tax.zones) && FLOW.endpoints.tax.zones.length && r.status==="READY"){
@@ -2319,40 +2562,63 @@
const ageReq=orderAgeReq(r);
if(ageReq && !r.ageVerified){
const gate=document.createElement("div"); gate.className="mgroup"; gate.style.cssText="border:1px solid var(--bad);border-radius:8px;padding:10px;margin-top:10px";
- gate.innerHTML="⚠ Age-restricted order
This order has items that require the customer to be "+ageReq+"+. Check a photo ID before selling.
";
- const ok=document.createElement("button"); ok.className="primary"; ok.style.marginTop="8px"; ok.textContent="✓ ID checked — customer is "+ageReq+"+";
- ok.onclick=()=>{ const st=boundStation(); const at=new Date().toISOString().slice(11,16);
- r.ageVerified={ req:ageReq, at, day:todayStr(), station:(st&&st.label)||"" }; // defensible record: what was required, when, at which station
- DB.ageLog=DB.ageLog||[]; DB.ageLog.push({ number:r.number, req:ageReq, at, day:todayStr(), station:(st&&st.label)||"" });
- logActivity("idcheck", "ID checked — customer verified "+ageReq+"+", r.number);
+ gate.innerHTML="⚠ Age-restricted order
This order has items that require the customer to be "+ageReq+"+. Check a photo ID and type the date of birth from it.
";
+ const row=document.createElement("div"); row.className="opts"; row.style.marginTop="8px";
+ const dob=document.createElement("input"); dob.id="dobInput"; dob.placeholder="Birthdate MM/DD/YYYY"; dob.autocomplete="off"; dob.style.minWidth="180px";
+ const ok=document.createElement("button"); ok.className="primary"; ok.textContent="Verify age";
+ // type the birthdate, we compute the age and REFUSE if it's under the requirement — no override tap
+ const verify=()=>{ const raw=(document.getElementById("dobInput")||{}).value; const age=ageFromDOB(raw);
+ const st=boundStation(); const at=new Date().toISOString().slice(11,16);
+ if(age==null){ flash("Type the date of birth as MM/DD/YYYY"); return; }
+ DB.ageLog=DB.ageLog||[];
+ if(age{ if(e.key==="Enter") verify(); };
+ row.appendChild(dob); row.appendChild(ok); gate.appendChild(row);
+ c.appendChild(gate); app.appendChild(c); return; // no tenders until a valid, of-age birthdate is entered
}
- // split the check evenly N ways (opt-in) — each tap takes one equal share until the balance clears
+ // split the check — evenly N ways, or BY SEAT (each guest pays their own) when the order was rung by seat
if(FLOW.endpoints.payment.split){
- const wrap=document.createElement("div"); wrap.className="mgroup"; wrap.innerHTML="Split evenly
";
+ const wrap=document.createElement("div"); wrap.className="mgroup"; wrap.innerHTML="Split the check
";
const opts=document.createElement("div"); opts.className="opts";
- [2,3,4].forEach(n=>{ const bb=document.createElement("button"); if(r.splitWays===n) bb.className="sel"; bb.textContent=n+" ways"; bb.onclick=()=>{ r.splitWays=n; r.splitShare=+(recordTotal(r)/n).toFixed(2); saveDB(DB); render(); }; opts.appendChild(bb); });
- const no=document.createElement("button"); if(!r.splitWays) no.className="sel"; no.textContent="No split"; no.onclick=()=>{ delete r.splitWays; delete r.splitShare; saveDB(DB); render(); }; opts.appendChild(no);
+ if(orderHasSeats(r)){ const sb=document.createElement("button"); if(r.splitMode==="seat") sb.className="sel"; sb.textContent="👤 By seat";
+ sb.onclick=()=>{ r.splitMode="seat"; delete r.splitWays; delete r.splitShare; const first=seatBuckets(r).find(x=>seatDue(r,x)>0.0001); r.paySeat=first?first.key:null; saveDB(DB); render(); }; opts.appendChild(sb); }
+ [2,3,4].forEach(n=>{ const bb=document.createElement("button"); if(r.splitMode!=="seat"&&r.splitWays===n) bb.className="sel"; bb.textContent=n+" ways"; bb.onclick=()=>{ r.splitMode="even"; r.splitWays=n; r.splitShare=+(recordTotal(r)/n).toFixed(2); delete r.paySeat; saveDB(DB); render(); }; opts.appendChild(bb); });
+ const no=document.createElement("button"); if(!r.splitWays&&r.splitMode!=="seat") no.className="sel"; no.textContent="No split"; no.onclick=()=>{ delete r.splitWays; delete r.splitShare; delete r.splitMode; delete r.paySeat; saveDB(DB); render(); }; opts.appendChild(no);
wrap.appendChild(opts); c.appendChild(wrap);
- if(r.splitWays){ const paidShares=Math.round((recordTotal(r)-bal)/r.splitShare); const info=document.createElement("div"); info.className="muted"; info.style.margin="4px 0"; info.textContent=r.splitWays+" shares · "+money(r.splitShare)+" each · "+paidShares+" of "+r.splitWays+" paid"; c.appendChild(info); }
+ if(r.splitMode==="seat"){ // one row per seat: its amount, paid ✓ or a "pay this seat" selector
+ seatBuckets(r).forEach(bkt=>{ const due=seatDue(r,bkt), amt=seatAmount(r,bkt), done=due<=0.0001;
+ const row=document.createElement("div"); row.className="line";
+ row.innerHTML=""+(done?"✓ ":"")+bkt.label+" — "+money(amt)+(done?"":" · "+money(due)+" due")+"";
+ if(!done){ const pick=document.createElement("button"); if(r.paySeat===bkt.key) pick.className="sel"; pick.textContent=r.paySeat===bkt.key?"paying":"pay this"; pick.onclick=()=>{ r.paySeat=bkt.key; saveDB(DB); render(); }; row.appendChild(pick); }
+ else { const t=document.createElement("span"); t.className="pill clock good"; t.textContent="paid"; row.appendChild(t); }
+ c.appendChild(row); });
+ } else if(r.splitWays){ const paidShares=Math.round((recordTotal(r)-bal)/r.splitShare); const info=document.createElement("div"); info.className="muted"; info.style.margin="4px 0"; info.textContent=r.splitWays+" shares · "+money(r.splitShare)+" each · "+paidShares+" of "+r.splitWays+" paid"; c.appendChild(info); }
}
- const payAmt = r.splitWays ? Math.min(r.splitShare, bal) : bal;
- const label = r.splitWays ? "Take share " : (paid>0?"Take balance ":"Take payment ");
- const b=document.createElement("button"); b.className="primary"; b.textContent=label+money(payAmt)+" cash"; b.style.marginTop="10px"; b.onclick=()=>{ r.tenders.push({type:"cash",amount:payAmt}); logActivity("money", "cash "+money(payAmt), r.number); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; c.appendChild(b);
+ const paySeatBkt = (r.splitMode==="seat"&&r.paySeat) ? seatBuckets(r).find(x=>x.key===r.paySeat) : null;
+ const payAmt = paySeatBkt ? Math.min(seatDue(r,paySeatBkt), bal) : (r.splitWays ? Math.min(r.splitShare, bal) : bal);
+ const label = paySeatBkt ? (paySeatBkt.label+" ") : (r.splitWays ? "Take share " : (paid>0?"Take balance ":"Take payment "));
+ const seatTag = () => (r.splitMode==="seat" && r.paySeat) ? r.paySeat : undefined; // tag a tender to its seat
+ const advanceSeat = () => { if(r.splitMode==="seat"){ const nx=seatBuckets(r).find(x=>seatDue(r,x)>0.0001); r.paySeat=nx?nx.key:null; } };
+ const b=document.createElement("button"); b.className="primary"; b.textContent=label+money(payAmt)+" cash"; b.style.marginTop="10px"; b.onclick=()=>{ r.tenders.push({type:"cash",amount:payAmt,seatKey:seatTag()}); logActivity("money", "cash "+money(payAmt), r.number); advanceSeat(); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; c.appendChild(b);
// make change: enter what the customer handed over and see the change due (the cash-drawer moment)
const mc=document.createElement("button"); mc.textContent="$ Make change"; mc.style.margin="10px 0 0 8px"; mc.onclick=()=>{ cashPad={ recId:r.id, received:payAmt }; render(); }; c.appendChild(mc);
if((FLOW.endpoints.payment.tenders||[]).includes("card")){
const cb=document.createElement("button"); cb.textContent=(r.splitWays?"Share by card ":"Pay by card ")+money(payAmt); cb.style.margin="10px 0 0 8px";
const idemKey=r.id+":card:"+Math.round(payAmt*100)+":"+cardTenders(r).length; // stable across a double-tap of THIS button
- cb.onclick=()=>{ const res=chargeCardOnce(r, payAmt, idemKey); if(res.status==="approved"){ if(!res.dedup){ const t=res.tender; logActivity("money", "card "+money(payAmt)+(t&&t.brand?" ("+t.brand+" ••"+t.last4+")":""), r.number); } if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); } else { logActivity("money", "card declined "+money(payAmt), r.number); flash("Card "+(res.message||"declined")); } };
+ cb.onclick=()=>{ const res=chargeCardOnce(r, payAmt, idemKey); if(res.status==="approved"){ if(!res.dedup){ const t=res.tender; if(t) t.seatKey=seatTag(); logActivity("money", "card "+money(payAmt)+(t&&t.brand?" ("+t.brand+" ••"+t.last4+")":""), r.number); advanceSeat(); } if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); } else { logActivity("money", "card declined "+money(payAmt), r.number); flash("Card "+(res.message||"declined")); } };
c.appendChild(cb);
}
// house account: put it on the customer's tab (A/R) — settles the order now, they pay it later
if((FLOW.endpoints.payment.tenders||[]).includes("account") && r.customer && r.customer.phone){
const ab=document.createElement("button"); ab.textContent="Charge to account "+money(payAmt); ab.style.margin="10px 0 0 8px";
- ab.onclick=()=>{ r.tenders.push({type:"account",amount:payAmt}); const cust=DB.customers.find(x=>x.phone===r.customer.phone); if(cust){ cust.balance=+((cust.balance||0)+payAmt).toFixed(2); } logActivity("money", "charged to account "+money(payAmt)+(r.customer?" · "+r.customer.name:""), r.number); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); }; c.appendChild(ab);
+ ab.onclick=()=>{ r.tenders.push({type:"account",amount:payAmt,seatKey:seatTag()}); const cust=DB.customers.find(x=>x.phone===r.customer.phone); if(cust){ cust.balance=+((cust.balance||0)+payAmt).toFixed(2); } logActivity("money", "charged to account "+money(payAmt)+(r.customer?" · "+r.customer.name:""), r.number); advanceSeat(); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); }; c.appendChild(ab);
}
// cash change-making: enter amount tendered, see change due, then take the cash
if(cashPad && cashPad.recId===r.id){
@@ -2365,7 +2631,7 @@
pad.appendChild(row);
const cd=document.createElement("div"); cd.id="changeDue"; cd.style.cssText="font-size:18px;font-weight:700;margin:8px 0"; cd.textContent="Change due "+money(Math.max(0,(cashPad.received||0)-payAmt)); pad.appendChild(cd);
const conf=document.createElement("button"); conf.id="cashConfirm"; conf.className="primary"; conf.textContent="✓ Take "+money(payAmt); conf.disabled=(cashPad.received||0){ const recv=+(cashPad.received||payAmt).toFixed(2); const chg=+(recv-payAmt).toFixed(2); r.tenders.push({ type:"cash", amount:payAmt, tendered:recv, change:chg }); logActivity("money", "cash "+money(payAmt)+" (tendered "+money(recv)+", change "+money(chg)+")", r.number); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; pad.appendChild(conf);
+ conf.onclick=()=>{ const recv=+(cashPad.received||payAmt).toFixed(2); const chg=+(recv-payAmt).toFixed(2); r.tenders.push({ type:"cash", amount:payAmt, tendered:recv, change:chg, seatKey:seatTag() }); logActivity("money", "cash "+money(payAmt)+" (tendered "+money(recv)+", change "+money(chg)+")", r.number); advanceSeat(); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; pad.appendChild(conf);
const cx=document.createElement("button"); cx.textContent="Cancel"; cx.style.marginLeft="8px"; cx.onclick=()=>{ cashPad=null; render(); }; pad.appendChild(cx);
c.appendChild(pad);
}
diff --git a/tests/age-check.js b/tests/age-check.js
index 8872cfd..5a18a3a 100644
--- a/tests/age-check.js
+++ b/tests/age-check.js
@@ -31,8 +31,9 @@ const FLOW = {
const gated = await T();
const blockedOk = /Age-restricted order/.test(gated) && /21\+/.test(gated) && !/Take payment/.test(gated);
- // check the ID -> payment unlocks
- await p.getByRole('button',{name:/ID checked/}).click();
+ // type an of-age birthdate -> payment unlocks
+ await p.locator('#dobInput').fill('06/20/1990');
+ await p.getByRole('button',{name:/Verify age/}).click();
const unlocked = await T();
const unlockedOk = /Take payment/.test(unlocked) && !/Age-restricted order/.test(unlocked);
await p.getByRole('button',{name:/Take payment/}).first().click();
diff --git a/tests/age-gate.js b/tests/age-gate.js
new file mode 100644
index 0000000..8bc587b
--- /dev/null
+++ b/tests/age-gate.js
@@ -0,0 +1,81 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 🔞 A server selling alcohol must card by TYPED BIRTHDATE — the POS computes the age and refuses an under-age
+// sale, no override tap. This drives the real full-service flow: ring a beer, fire it, then at payment the age
+// gate demands a birthdate; an under-21 DOB is refused and logged, an of-age DOB clears the gate and opens tenders.
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+
+ // build the full-service (server) template — it has a bar with age-restricted drinks
+ await p.locator('button.trade').filter({ hasText: 'Full-service' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ // unit-check the age math + the restricted flag, straight out of the generated engine (same-origin iframe)
+ const unit = await p.evaluate(async () => {
+ const w = document.querySelector('#preview').contentWindow;
+ for (let i=0;i<60 && !(w.ageFromDOB && w.CUSTOMPOS_FLOW);i++) await new Promise(r=>setTimeout(r,100));
+ const f = w.ageFromDOB, beer = w.CUSTOMPOS_FLOW.catalog.find(x=>x.name==='Draft Beer');
+ return { minorUnder21: f('01/15/2010') < 21, adultOver21: f('06/20/1990') >= 21,
+ garbageNull: f('hello')===null, badDateNull: f('02/30/2000')===null, futureNull: f('01/01/2050')===null,
+ isoWorks: typeof f('1995-03-02')==='number', beerRestricted: beer && beer.ageRestricted===21 };
+ });
+
+ // drive the full server flow to a READY alcohol order, then exercise the gate
+ const gate = await p.evaluate(async () => {
+ const doc=()=>document.querySelector('#preview').contentDocument;
+ const w=document.querySelector('#preview').contentWindow;
+ const wait=ms=>new Promise(r=>setTimeout(r,ms));
+ const clickBtn=re=>{const el=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;};
+ const clickLink=re=>{const el=[...doc().querySelectorAll('a')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;};
+ clickBtn(/^Got it/); clickBtn(/^Server Station/); await wait(150);
+ const beer=[...doc().querySelectorAll('.tile')].find(t=>/Draft Beer/.test(t.textContent)); if(beer)beer.click(); await wait(100);
+ clickBtn(/Send order/); await wait(150);
+ clickLink(/change station/); await wait(120); clickBtn(/^Bar/); await wait(150);
+ clickBtn(/Mark done here/); await wait(200);
+ clickLink(/change station/); await wait(120); clickBtn(/^Server Station/); await wait(200);
+ const readyStatus=(w.loadDB().records||[]).map(r=>r.status);
+ const openIt=[...doc().querySelectorAll('button, .card, .line, a')].find(x=>/Draft Beer/.test(x.textContent||'')); if(openIt)openIt.click(); await wait(200);
+ const gateShown=!!doc().querySelector('#dobInput');
+ // under-age → refused, still gated, logged
+ doc().querySelector('#dobInput').value='01/15/2010';
+ (function(){const v=[...doc().querySelectorAll('button')].find(x=>/Verify age/.test(x.textContent));if(v)v.click();})(); await wait(200);
+ const recA=(w.loadDB().records||[])[0];
+ const minorBlocked = !recA.ageVerified && !!doc().querySelector('#dobInput');
+ const refusedLogged = (w.loadDB().ageLog||[]).some(a=>a.result==='refused' && a.age<21);
+ // of-age → verified, gate clears, tenders open
+ doc().querySelector('#dobInput').value='06/20/1990';
+ (function(){const v=[...doc().querySelectorAll('button')].find(x=>/Verify age/.test(x.textContent));if(v)v.click();})(); await wait(200);
+ const recB=(w.loadDB().records||[])[0];
+ const adultVerified = !!(recB.ageVerified && recB.ageVerified.age>=21 && recB.ageVerified.method==='dob');
+ const gateGone = !doc().querySelector('#dobInput');
+ const tenderShown = [...doc().querySelectorAll('button')].some(x=>/Cash|Card|Pay|Charge|Tender/.test(x.textContent));
+ const noRawDob = !('dob' in recB.ageVerified) && !('birthdate' in recB.ageVerified); // we store the computed age, never the raw DOB
+ return { readyStatus, gateShown, minorBlocked, refusedLogged, adultVerified, gateGone, tenderShown, noRawDob };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('age math (minor<21, adult>=21, iso ok, garbage/badDate/future null):', unit.minorUnder21 && unit.adultOver21 && unit.isoWorks && unit.garbageNull && unit.badDateNull && unit.futureNull);
+ console.log('bar drink is age-restricted (21):', unit.beerRestricted);
+ console.log('order fires to READY and the age gate demands a birthdate:', JSON.stringify(gate.readyStatus)==='["READY"]' && gate.gateShown);
+ console.log('under-age birthdate is REFUSED, still gated, and logged:', gate.minorBlocked && gate.refusedLogged);
+ console.log('of-age birthdate verifies, clears the gate, opens tenders:', gate.adultVerified && gate.gateGone && gate.tenderShown);
+ console.log('the raw DOB is NOT stored (only the computed age):', gate.noRawDob);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = unit.minorUnder21 && unit.adultOver21 && unit.isoWorks && unit.garbageNull && unit.badDateNull && unit.futureNull && unit.beerRestricted
+ && JSON.stringify(gate.readyStatus)==='["READY"]' && gate.gateShown && gate.minorBlocked && gate.refusedLogged
+ && gate.adultVerified && gate.gateGone && gate.tenderShown && gate.noRawDob && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/burgerbarn.js b/tests/burgerbarn.js
index 768fcea..4cd50ed 100644
--- a/tests/burgerbarn.js
+++ b/tests/burgerbarn.js
@@ -14,20 +14,28 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i
p.on('pageerror', e => errors.push('pageerror: '+e.message));
await p.goto(`http://127.0.0.1:${port}/builder.html`);
- // build the real Hamburger Barn template
- await p.locator('button.trade').filter({ hasText: 'burger-joint' }).click();
+ // build the real Hamburger Barn template (its trade button is the only one labeled "Hamburger Barn")
+ await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click();
await p.getByRole('button',{name:/Build it for me/}).click();
await p.waitForFunction(() => window.__build && window.__build.html);
const cfg = await p.evaluate(() => {
const f = window.__build.flow, byName = n => f.catalog.find(i=>i.name===n);
+ const cats = {}; f.catalog.forEach(i=>{ cats[i.category]=1; });
+ const combo = (byName('Bubba Burger').addons||[]).some(a=>/combo/i.test(a.name));
return {
name: f.branding.name,
bubbaToGrill: (byName('Bubba Burger').path||[]).join()==='grill',
onionToFry: (byName('Onion Rings').path||[]).join()==='fry',
shakeToShakes: (byName('Milkshake').path||[]).join()==='shakes',
+ saladToPrep: (byName('House Salad').path||[]).join()==='prep',
+ chimiToFry: (byName('Chicken Chimichanga').path||[]).join()==='fry',
+ fajitaToGrill: (byName('Flaming Fajitas').path||[]).join()==='grill',
specialPar: byName('Blue Plate Special').par===10,
hasTip: !!f.endpoints.payment.tip,
+ hasCombo: combo,
+ catCount: Object.keys(cats).length,
+ hasCatLabels: !!(f.catLabels && f.catLabels.burger),
itemCount: f.catalog.length
};
});
@@ -35,6 +43,9 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i
// drive the ACTUAL generated POS in the live preview: one order fans out to grill + fry
const fr = p.frameLocator('#preview');
await fr.getByText('Front Counter', { exact:false }).first().click();
+ // 🍔 Toast-style category tabs render for a big menu (wait for the ordering screen to paint before counting)
+ await fr.locator('.cattabs button').first().waitFor();
+ const tabCount = await fr.locator('.cattabs button').count();
await fr.getByText('Bubba Burger', { exact:false }).first().click();
await fr.getByRole('button',{ name:/^Single/ }).first().click(); // required Patty choice
await fr.getByRole('button',{ name:/Add to order/ }).click();
@@ -54,12 +65,16 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i
console.log('grill:', grill.replace(/\n+/g,' | '));
console.log('fry:', fry.replace(/\n+/g,' | '));
console.log('\n=== RESULTS ===');
- console.log('real menu built ('+cfg.itemCount+' items), Hamburger Barn:', cfg.name==='Hamburger Barn' && cfg.itemCount>=18);
- console.log('items routed right (burger→grill, rings→fry, shake→shakes):', cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes);
+ console.log('full menu built ('+cfg.itemCount+' items across '+cfg.catCount+' categories), Hamburger Barn:', cfg.name==='Hamburger Barn' && cfg.itemCount>=40 && cfg.catCount>=10);
+ console.log('items routed right (burger→grill, rings→fry, shake→shakes, salad→prep, chimi→fry, fajita→grill):', cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes && cfg.saladToPrep && cfg.chimiToFry && cfg.fajitaToGrill);
+ console.log('Toast-style: "make it a combo" upsell + category labels:', cfg.hasCombo && cfg.hasCatLabels);
console.log('daily special has an 86 count; tips on:', cfg.specialPar && cfg.hasTip);
+ console.log('Toast-style category tabs render on the ordering screen ('+tabCount+' tabs):', tabCount>=5);
console.log('live POS fan-out: Grill sees the burger only:', grillOk);
console.log('live POS fan-out: Fry sees the onion rings only:', fryOk);
console.log('console errors:', errors.length?errors:'NONE');
- const ok = cfg.name==='Hamburger Barn' && cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes && cfg.specialPar && cfg.hasTip && grillOk && fryOk && !errors.length;
+ const ok = cfg.name==='Hamburger Barn' && cfg.itemCount>=40 && cfg.catCount>=10 &&
+ cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes && cfg.saladToPrep && cfg.chimiToFry && cfg.fajitaToGrill &&
+ cfg.hasCombo && cfg.hasCatLabels && cfg.specialPar && cfg.hasTip && tabCount>=5 && grillOk && fryOk && !errors.length;
process.exit(ok?0:1);
})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/comp.js b/tests/comp.js
new file mode 100644
index 0000000..cc2dbac
--- /dev/null
+++ b/tests/comp.js
@@ -0,0 +1,49 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 🎁 COMP a line — take an item to $0 with a reason on the record (distinct from a %-off discount). Reversible.
+// Manager approval is opt-in per instance (FLOW.endpoints.approvals.comp); here we pin the core comp/un-comp
+// behavior + that it lands on the activity log as its own 'comp' action.
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+
+ await p.locator('button.trade').filter({ hasText: 'Full-service' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(() => {
+ const w=document.querySelector('#preview').contentWindow;
+ w.prompt=(m)=>/PIN/i.test(m)?'2222':'Kitchen error'; // reason, then a manager PIN (bistro gates comps on approval)
+ const r={ id:'RC', number:77, status:'READY', tenders:[], lines:[ {id:'x',name:'Ribeye',price:34,qty:1,seat:1}, {id:'y',name:'Wine',price:11,qty:1,seat:2} ] };
+ w.DB=w.loadDB(); w.DB.records=[r]; w.DB.activity=w.DB.activity||[]; w.saveDB(w.DB);
+ const before=w.recordTotal(r);
+ w.compLine(r, r.lines[0]);
+ const l=r.lines[0];
+ const compedOk = l.price===0 && !!l.comped && l.comped.reason==='Kitchen error' && l.comped.wasPrice===34 && w.recordTotal(r)===11;
+ const chipOk = /comp/.test(w.compChip(l));
+ const logged = (w.loadDB().activity||[]).some(a=>a.type==='comp' && /Ribeye/.test(a.detail||''));
+ w.compLine(r, r.lines[0]); // un-comp
+ const restoredOk = r.lines[0].price===34 && !r.lines[0].comped && w.recordTotal(r)===45;
+ return { before, compedOk, chipOk, logged, restoredOk };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('comping a line takes it to $0 with a reason; order total drops (45→11):', out.before===45 && out.compedOk);
+ console.log('the comped line shows a comp badge:', out.chipOk);
+ console.log('the comp is logged as its own audit action:', out.logged);
+ console.log('un-comp restores the price and total (→45):', out.restoredOk);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.before===45 && out.compedOk && out.chipOk && out.logged && out.restoredOk && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/dayparts.js b/tests/dayparts.js
new file mode 100644
index 0000000..985fa23
--- /dev/null
+++ b/tests/dayparts.js
@@ -0,0 +1,47 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// ⏰ DAY-PART MENUS + HAPPY-HOUR PRICING — items can carry an availability window (breakfast till 11) and a
+// happy-hour price that applies inside a shop window. Time is injected so the test is deterministic.
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Full-service' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(() => {
+ const w=document.querySelector('#preview').contentWindow;
+ const wine=w.CUSTOMPOS_FLOW.catalog.find(x=>x.name==='House Wine');
+ const at4=new Date(2026,0,1,16,0), at12=new Date(2026,0,1,12,0), at10=new Date(2026,0,1,10,0);
+ const brunch={from:"09:00",to:"11:00"};
+ return {
+ wineHasHappy: wine && wine.happyPrice===6,
+ happyOn4: w.happyActive(at4), happyOff12: w.happyActive(at12),
+ wine4: w.itemPriceNow(wine,at4), wine12: w.itemPriceNow(wine,at12),
+ brunchOpen: w.itemAvailNow({avail:brunch}, at10), brunchClosed: w.itemAvailNow({avail:brunch}, at4),
+ allDay: w.itemAvailNow({}, at4),
+ overnight: w.withinWindow({from:"22:00",to:"02:00"}, new Date(2026,0,1,1,0))
+ };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('bistro bar drink carries a happy-hour price:', out.wineHasHappy);
+ console.log('happy hour is on at 4pm, off at noon:', out.happyOn4 && !out.happyOff12);
+ console.log('wine rings $6 in happy hour, $11 otherwise:', out.wine4===6 && out.wine12===11);
+ console.log('day-part item available in-window (10am), hidden out (4pm):', out.brunchOpen && !out.brunchClosed);
+ console.log('an item with no window is always available; overnight window wraps midnight:', out.allDay && out.overnight);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.wineHasHappy && out.happyOn4 && !out.happyOff12 && out.wine4===6 && out.wine12===11 && out.brunchOpen && !out.brunchClosed && out.allDay && out.overnight && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/nested-modifiers.js b/tests/nested-modifiers.js
new file mode 100644
index 0000000..937b5af
--- /dev/null
+++ b/tests/nested-modifiers.js
@@ -0,0 +1,49 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 🔀 CONDITIONAL / NESTED MODIFIERS: a modifier option or add-on can `reveal` a follow-up group that only shows
+// once it's chosen (Bubba "Extra cheese" → required "Which cheese"), gates Add until answered, and rides onto the line.
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(async () => {
+ const doc=()=>document.querySelector('#preview').contentDocument;
+ const wait=ms=>new Promise(r=>setTimeout(r,ms));
+ const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;};
+ cB(/^Got it/); cB(/^Front Counter/); await wait(180);
+ const bubba=[...doc().querySelectorAll('.tile')].find(x=>/Bubba Burger/.test(x.textContent)); if(bubba)bubba.click(); await wait(120);
+ const hiddenBefore=!/Which cheese/.test(doc().body.innerText);
+ cB(/^Single/); cB(/Extra cheese/); await wait(120);
+ const shownAfter=/Which cheese/.test(doc().body.innerText);
+ const addBtn=[...doc().querySelectorAll('button')].find(x=>/Add to order/.test(x.textContent));
+ const gatedUntilAnswered=!!(addBtn && addBtn.disabled);
+ cB(/^Swiss/); await wait(100);
+ const addBtn2=[...doc().querySelectorAll('button')].find(x=>/Add to order/.test(x.textContent));
+ const enabledAfter=!!(addBtn2 && !addBtn2.disabled);
+ cB(/Add to order/); await wait(100);
+ const line=[...doc().querySelectorAll('.line')].map(l=>l.textContent).find(x=>/Bubba/.test(x))||"";
+ return { hiddenBefore, shownAfter, gatedUntilAnswered, enabledAfter, lineHasCheese:/Swiss/.test(line) };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('the revealed group is hidden until its trigger is chosen:', out.hiddenBefore && out.shownAfter);
+ console.log('a required reveal gates "Add" until answered:', out.gatedUntilAnswered && out.enabledAfter);
+ console.log('the nested choice rides onto the line:', out.lineHasCheese);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.hiddenBefore && out.shownAfter && out.gatedUntilAnswered && out.enabledAfter && out.lineHasCheese && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/order-types.js b/tests/order-types.js
new file mode 100644
index 0000000..9e44750
--- /dev/null
+++ b/tests/order-types.js
@@ -0,0 +1,45 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 🚗 ORDER TYPES (dine-in / to-go / drive-thru) + the online/delivery CHANNEL hook. The intake picker sets the
+// type; it rides onto the record, the ticket and the KDS chip. Channel is the display hook for an order handed
+// in by the hub from online ordering / a delivery app (that ingestion is hub + account work, not the engine).
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(async () => {
+ const doc=()=>document.querySelector('#preview').contentDocument;
+ const w=document.querySelector('#preview').contentWindow;
+ const wait=ms=>new Promise(r=>setTimeout(r,ms));
+ const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;};
+ cB(/^Got it/); cB(/^Front Counter/); await wait(200);
+ const picker=[...doc().querySelectorAll('button')].map(b=>b.textContent.trim()).filter(x=>/^Dine-in$|^To-go$|^Drive-thru$/.test(x));
+ const typeChip=w.orderTypeChip({orderType:"Drive-thru"});
+ const chanChip=w.channelChip({channel:"DoorDash"});
+ const cfgTypes=(w.CUSTOMPOS_FLOW.endpoints.orderTypes||[]).join(",");
+ return { picker, typeChip, chanChip, cfgTypes };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('intake shows the order-type picker (Dine-in/To-go/Drive-thru):', out.picker.length===3);
+ console.log('config carries the order types:', out.cfgTypes==="Dine-in,To-go,Drive-thru");
+ console.log('order-type chip renders the type:', /Drive-thru/.test(out.typeChip));
+ console.log('channel chip renders an online/delivery source:', /DoorDash/.test(out.chanChip));
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.picker.length===3 && out.cfgTypes==="Dine-in,To-go,Drive-thru" && /Drive-thru/.test(out.typeChip) && /DoorDash/.test(out.chanChip) && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/report-comps.js b/tests/report-comps.js
new file mode 100644
index 0000000..a2b1d83
--- /dev/null
+++ b/tests/report-comps.js
@@ -0,0 +1,44 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 🧾 DAILY CLOSE surfaces COMPS. The report already shows net/tax/tips/tenders/drawer/COGS/waste; this pins that
+// the value given away as comps shows as its own line so the owner sees it at close.
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Full-service' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(async () => {
+ const doc=()=>document.querySelector('#preview').contentDocument;
+ const w=document.querySelector('#preview').contentWindow;
+ const wait=ms=>new Promise(r=>setTimeout(r,ms));
+ // a PAID order today: $30 sold + a $20 comped line
+ w.DB=w.loadDB();
+ w.DB.records=[{id:'RP',number:1,status:'PAID',ts:Date.now(),tenders:[{type:'cash',amount:30}],
+ lines:[{id:'a',name:'Steak',price:30,qty:1,category:'entree'},{id:'b',name:'Wine',price:0,qty:1,category:'drink',comped:{reason:'Kitchen error',amount:20,wasPrice:20}}]}];
+ w.saveDB(w.DB);
+ const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;};
+ cB(/^Got it/); cB(/^Office/); await wait(250);
+ const txt=doc().body.innerText;
+ return { onReport:/Sales summary/.test(txt), net:/Net sales/.test(txt), comps:/Comps \(on the house\)/.test(txt), amt:/20\.00/.test(txt) };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('the daily close renders a sales summary:', out.onReport && out.net);
+ console.log('comps show as their own line with the amount given away:', out.comps && out.amt);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.onReport && out.net && out.comps && out.amt && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/reservations.js b/tests/reservations.js
new file mode 100644
index 0000000..5959060
--- /dev/null
+++ b/tests/reservations.js
@@ -0,0 +1,57 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 🕐 RESERVATIONS + WAITLIST (the host stand on the floor): book/seat/cancel reservations; add/seat/clear the
+// waitlist. Seating onto a table occupies it. Nothing is hard-deleted (cancelled / left / seated).
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Full-service' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(async () => {
+ const doc=()=>document.querySelector('#preview').contentDocument;
+ const w=document.querySelector('#preview').contentWindow;
+ const wait=ms=>new Promise(r=>setTimeout(r,ms));
+ const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;};
+ cB(/^Got it/); cB(/^Floor/); await wait(200);
+ const hostCards=/Reservations/.test(doc().body.innerText) && /Waitlist/.test(doc().body.innerText);
+ w.addReservation('Test Party','4','7:00',''); w.addWaitlist('Joe','2','15','');
+ let db=w.loadDB();
+ const booked=(db.reservations||[]).some(r=>r.name==='Test Party' && r.status==='booked' && r.party===4);
+ const waiting=(db.waitlist||[]).some(x=>x.name==='Joe' && x.status==='waiting');
+ const rid=(db.reservations||[]).slice(-1)[0].id, wid=(db.waitlist||[]).slice(-1)[0].id;
+ const empty=w.floorStates()[0].id;
+ w.selTable='t2'; w.seatReservation(rid,'t2');
+ w.selTable='t3'; w.seatWaitlist(wid,'t3');
+ db=w.loadDB();
+ const seatedResv=(db.reservations||[]).find(r=>r.id===rid); const seatedWl=(db.waitlist||[]).find(x=>x.id===wid);
+ const seatedOk = seatedResv.status==='seated' && seatedResv.tableId==='t2' && (db.tables.t2||{}).state!==empty
+ && seatedWl.status==='seated' && (db.tables.t3||{}).state!==empty;
+ // cancel a fresh reservation, and it leaves the active list (soft, not deleted)
+ w.addReservation('Cancel Me','2','8:00',''); db=w.loadDB(); const cid=(db.reservations||[]).slice(-1)[0].id;
+ w.cancelReservation(cid); db=w.loadDB();
+ const cancelledSoft = (db.reservations||[]).some(r=>r.id===cid && r.status==='cancelled') && !w.todayReservations().some(r=>r.id===cid);
+ return { hostCards, booked, waiting, seatedOk, cancelledSoft };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('host stand shows Reservations + Waitlist cards:', out.hostCards);
+ console.log('a reservation books and a walk-in joins the waitlist:', out.booked && out.waiting);
+ console.log('seating a reservation/walk-in onto a table occupies it:', out.seatedOk);
+ console.log('cancelling is soft (kept, dropped from the active list):', out.cancelledSoft);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.hostCards && out.booked && out.waiting && out.seatedOk && out.cancelledSoft && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/seat-order.js b/tests/seat-order.js
new file mode 100644
index 0000000..54d5436
--- /dev/null
+++ b/tests/seat-order.js
@@ -0,0 +1,49 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 👤 SEAT-BY-SEAT ordering: pick a table, pick a seat, and each item rings to that seat. Pins the seat picker,
+// the per-line seat stamp (draft shows 👤 S1 / S2), and the seatChip helper. (The seat then rides onto the
+// record + KDS via the same l.seat field — verified live.)
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Full-service' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(async () => {
+ const doc=()=>document.querySelector('#preview').contentDocument;
+ const w=document.querySelector('#preview').contentWindow;
+ const wait=ms=>new Promise(r=>setTimeout(r,ms));
+ const cB=re=>{const el=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;};
+ const cT=re=>{const el=[...doc().querySelectorAll('.tile')].find(x=>re.test(x.textContent));if(el){el.click();return true;}return false;};
+ cB(/^Got it/); cB(/^Server Station/); await wait(150);
+ cB(/^1$/); await wait(120);
+ const seatPicker = [...doc().querySelectorAll('button')].some(x=>/^Seat 1/.test(x.textContent.trim())) &&
+ [...doc().querySelectorAll('button')].some(x=>/Whole table/.test(x.textContent.trim()));
+ cB(/^Seat 1/); await wait(60); cT(/Calamari/); await wait(120);
+ cB(/^Seat 2/); await wait(60); cT(/Ribeye/); await wait(80); cB(/^Rare|^Med rare|^Medium/); cB(/Add to order/); await wait(120);
+ const chipS1=[...doc().querySelectorAll('.line')].some(l=>/Calamari/.test(l.textContent)&&/S1/.test(l.textContent));
+ const chipS2=[...doc().querySelectorAll('.line')].some(l=>/Ribeye/.test(l.textContent)&&/S2/.test(l.textContent));
+ const chipHelper=/S7/.test(w.seatChip({seat:7})) && w.seatChip({})==="";
+ return { seatPicker, chipS1, chipS2, chipHelper };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('picking a table shows the seat picker (Whole table + Seat N):', out.seatPicker);
+ console.log('items ring to the chosen seat (draft shows S1 / S2):', out.chipS1 && out.chipS2);
+ console.log('seatChip renders a seat and is blank when unseated:', out.chipHelper);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.seatPicker && out.chipS1 && out.chipS2 && out.chipHelper && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/seat-receipt.js b/tests/seat-receipt.js
new file mode 100644
index 0000000..6cee29f
--- /dev/null
+++ b/tests/seat-receipt.js
@@ -0,0 +1,43 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 👤🧾 PER-SEAT RECEIPT: when an order was rung by seat, the receipt groups its items under each seat (+ Shared)
+// with per-seat subtotals — the itemized companion to split-by-seat.
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Full-service' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(() => {
+ const w=document.querySelector('#preview').contentWindow;
+ const seated={ id:'RR', number:9, status:'READY', tenders:[], lines:[ {id:'a',name:'Calamari',price:12,qty:1,seat:1}, {id:'b',name:'Ribeye',price:34,qty:1,seat:2}, {id:'c',name:'Bread',price:6,qty:1,seat:null} ] };
+ const seatedHtml=w.receiptPanel(seated).innerHTML;
+ const flat={ id:'RF', number:10, status:'READY', tenders:[], lines:[ {id:'x',name:'Burger',price:9,qty:1} ] };
+ const flatHtml=w.receiptPanel(flat).innerHTML;
+ return {
+ grouped: /Seat 1/.test(seatedHtml) && /Seat 2/.test(seatedHtml) && /Shared/.test(seatedHtml) && /Calamari/.test(seatedHtml) && /Ribeye/.test(seatedHtml),
+ total: /Total/.test(seatedHtml),
+ flatUngrouped: !/Seat 1/.test(flatHtml) && /Burger/.test(flatHtml)
+ };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('a seated order receipt groups items by seat (+ Shared) with subtotals:', out.grouped);
+ console.log('it still shows the order total:', out.total);
+ console.log('a non-seated order receipt stays a flat list:', out.flatUngrouped);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.grouped && out.total && out.flatUngrouped && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/server-assign.js b/tests/server-assign.js
new file mode 100644
index 0000000..1b7bcf7
--- /dev/null
+++ b/tests/server-assign.js
@@ -0,0 +1,53 @@
+const { chromium } = require('playwright-core');
+const url = ('file://' + require('path').resolve(__dirname, '..', 'pos.html'));
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+// 🧑🍳 SERVER ASSIGNMENT + "my tables": a server owns tables; the floor can filter to just theirs, and the
+// assignment survives service-state changes and clears when the table is bussed empty.
+const FLOW = {
+ flowId:"bistro", label:"Bistro", topology:"linear",
+ branding:{ name:"Bistro", brandColor:"#7a1f2b" },
+ endpoints:{ customer:{persist:false}, payment:{tenders:["cash"], closeGate:"balanceLE0" } },
+ staff:[ {id:"s1",name:"Riley",pin:"1111"}, {id:"s2",name:"Sam",pin:"2222"} ],
+ floor:{ label:"Dining Room", tables:[ {id:"t1",label:"1",seats:2}, {id:"t2",label:"2",seats:4} ] },
+ catalog:[ {id:"x", name:"Plate", price:20, category:"food", path:[] } ],
+ stations:[ {id:"floor", type:"floor", label:"Floor", view:{} }, {id:"reg", type:"central", label:"Server", view:{money:true} } ]
+};
+(async () => {
+ const errors = [];
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.addInitScript(f => { window.CUSTOMPOS_FLOW = f; }, FLOW);
+ await p.goto(url);
+ const T = async () => (await p.locator('main').innerText());
+ const server = async id => p.evaluate(i => (JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k=>/pos/i.test(k))||"")||"{}").tables||{})[i], id);
+
+ await p.getByRole('button',{name:/^Floor/}).first().click();
+ const barOk = /Tables:/.test(await T()); // the "my tables" server filter bar
+
+ // assign Riley to table 1 via the table panel
+ await p.locator('.tabletile').filter({ hasText: /^1/ }).click();
+ await p.locator('.card').filter({ hasText: 'Server:' }).getByRole('button',{ name:'Riley' }).click();
+ const assignedTile = /🧑🍳 Riley/.test(await T());
+ const assignedDb = await p.evaluate(()=>{ const w=window; return (w.DB&&w.DB.tables&&w.DB.tables.t1&&w.DB.tables.t1.server)|| (w.tableServer?w.tableServer('t1'):null); });
+
+ // "my tables" — filter to Riley: only table 1 remains
+ await p.locator('.opts').filter({ hasText:'Tables:' }).getByRole('button',{ name:'Riley' }).click();
+ const onlyMine = await p.locator('.tabletile').count();
+
+ // assignment persists across a state change; back to All
+ await p.locator('.opts').filter({ hasText:'Tables:' }).getByRole('button',{ name:/^All$/ }).click();
+ await p.locator('.tabletile').filter({ hasText: /^1/ }).click();
+ await p.getByRole('button',{name:/→ Seated/}).click();
+ const persists = /🧑🍳 Riley/.test(await T());
+
+ await b.close();
+ console.log('\n=== RESULTS ===');
+ console.log('floor shows the "my tables" server filter:', barOk);
+ console.log('assigning a server tags the table (tile + DB):', assignedTile && assignedDb==='Riley');
+ console.log('"my tables" filters the floor to one server (1 table):', onlyMine===1);
+ console.log('the assignment survives a service-state change:', persists);
+ console.log('console errors:', errors.length?errors:'NONE');
+ process.exit(errors.length||!barOk||!assignedTile||assignedDb!=='Riley'||onlyMine!==1||!persists?1:0);
+})().catch(e=>{ console.error('FATAL',e); process.exit(2); });
diff --git a/tests/split-seat.js b/tests/split-seat.js
new file mode 100644
index 0000000..8191268
--- /dev/null
+++ b/tests/split-seat.js
@@ -0,0 +1,53 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 💳👤 SPLIT & PAY BY SEAT — each guest pays their own. Drives the engine's own seat-split math + tender model
+// on a real (built) engine: one bucket per seat (+ a "Shared" bucket), each seat's amount is its share of the
+// total, tenders tag to a seat, and the order only clears when every seat is paid. (UI wiring verified live;
+// this pins the money logic deterministically so it can't drift.)
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Full-service' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(() => {
+ const w=document.querySelector('#preview').contentWindow;
+ // seat1 $12, seat2 $34, shared $16 — no tax so the shares are exact
+ const r={ id:"RT", number:99, status:"READY", tenders:[], splitMode:"seat",
+ lines:[ {id:"a",name:"A",price:12,qty:1,seat:1}, {id:"b",name:"B",price:34,qty:1,seat:2}, {id:"c",name:"C",price:16,qty:1,seat:null} ] };
+ const bkts=w.seatBuckets(r); const byLabel=l=>bkts.find(x=>x.label===l);
+ const amounts={ s1:w.seatAmount(r,byLabel("Seat 1")), s2:w.seatAmount(r,byLabel("Seat 2")), shared:w.seatAmount(r,byLabel("Shared")) };
+ const bucketCount=bkts.length;
+ // pay each bucket with a seat-tagged tender; balance must only clear when all are paid
+ r.tenders.push({type:"cash",amount:12,seatKey:"s1"}); const bal1=w.recordBalance(r); const s1cleared=w.seatDue(r,byLabel("Seat 1"))===0;
+ r.tenders.push({type:"card",amount:34,seatKey:"s2"}); const bal2=w.recordBalance(r);
+ const stillOpen = bal2>0.0001; // shared not yet paid
+ r.tenders.push({type:"cash",amount:16,seatKey:"shared"});
+ const balEnd=w.recordBalance(r);
+ const allTagged=(r.tenders||[]).every(t=>t.seatKey);
+ return { total:w.recordTotal(r), amounts, bucketCount, bal1, s1cleared, stillOpen, balEnd, allTagged };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('total $62; one bucket per seat + shared (3):', out.total===62 && out.bucketCount===3);
+ console.log('each seat billed its share (S1 12, S2 34, Shared 16):', out.amounts.s1===12 && out.amounts.s2===34 && out.amounts.shared===16);
+ console.log('paying seat 1 clears only seat 1 (bal 50):', out.bal1===50 && out.s1cleared);
+ console.log('order stays open until the shared bucket is paid too:', out.stillOpen && out.balEnd===0);
+ console.log('every tender is tagged to its seat:', out.allTagged);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.total===62 && out.bucketCount===3 && out.amounts.s1===12 && out.amounts.s2===34 && out.amounts.shared===16
+ && out.bal1===50 && out.s1cleared && out.stillOpen && out.balEnd===0 && out.allTagged && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/table-transfer.js b/tests/table-transfer.js
new file mode 100644
index 0000000..bd1cc8f
--- /dev/null
+++ b/tests/table-transfer.js
@@ -0,0 +1,46 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 🔀 TRANSFER / MERGE tables: move a table's open check to another table — a transfer when the target is empty,
+// a merge when it already has a check. The source table is freed either way. (Money logic pinned on the built engine.)
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Full-service' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(() => {
+ const w=document.querySelector('#preview').contentWindow;
+ w.DB=w.loadDB();
+ w.DB.records=[ {id:'A1',number:1,status:'READY',tableId:'t1',tenders:[],lines:[{id:'a',name:'Steak',price:30,qty:1}]},
+ {id:'B1',number:2,status:'READY',tableId:'t2',tenders:[],lines:[{id:'b',name:'Wine',price:11,qty:1}]} ];
+ const empty=w.floorStates?w.floorStates()[0].id:'empty';
+ w.DB.tables={ t1:{state:'assigned',since:Date.now(),server:'Riley'}, t2:{state:'assigned',since:Date.now(),server:'Sam'} };
+ w.saveDB(w.DB);
+ w.moveTable('t1','t2'); // merge (t2 occupied)
+ let db=w.loadDB();
+ const merge={ onT2:(db.records||[]).filter(r=>r.tableId==='t2').length, onT1:(db.records||[]).filter(r=>r.tableId==='t1').length, t1empty:(db.tables.t1||{}).state===empty };
+ w.moveTable('t2','t3'); // transfer (t3 empty)
+ db=w.loadDB();
+ const transfer={ onT3:(db.records||[]).filter(r=>r.tableId==='t3').length, t3occupied:(db.tables.t3||{}).state && (db.tables.t3||{}).state!==empty, t2empty:(db.tables.t2||{}).state===empty };
+ return { merge, transfer };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('merge onto an occupied table combines checks + frees the source:', out.merge.onT2===2 && out.merge.onT1===0 && out.merge.t1empty);
+ console.log('transfer to an empty table moves the checks + occupies it:', out.transfer.onT3===2 && out.transfer.t3occupied && out.transfer.t2empty);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.merge.onT2===2 && out.merge.onT1===0 && out.merge.t1empty && out.transfer.onT3===2 && out.transfer.t3occupied && out.transfer.t2empty && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/tip-out.js b/tests/tip-out.js
new file mode 100644
index 0000000..a749859
--- /dev/null
+++ b/tests/tip-out.js
@@ -0,0 +1,43 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 💸 TIP-OUT BY ROLE at close: when configured (tip.tipOut=[{role,pct}]), the report spells out each support
+// role's cut of today's tips and what the servers keep — instead of doing it by hand for payroll.
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Full-service' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(async () => {
+ const doc=()=>document.querySelector('#preview').contentDocument;
+ const w=document.querySelector('#preview').contentWindow;
+ const wait=ms=>new Promise(r=>setTimeout(r,ms));
+ w.DB=w.loadDB();
+ w.DB.records=[{id:'RT',number:1,status:'PAID',ts:Date.now(),tip:100,tenders:[{type:'card',amount:200}],lines:[{id:'a',name:'Steak',price:100,qty:1,category:'entree'}]}];
+ w.saveDB(w.DB);
+ const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;};
+ cB(/^Got it/); cB(/^Office/); await wait(280);
+ const txt=doc().body.innerText;
+ return { shown:/Tip-out/.test(txt), kitchen:/Kitchen \(5%\)/.test(txt)&&/5\.00/.test(txt), bussers:/Bussers \(10%\)/.test(txt)&&/10\.00/.test(txt), kept:/Kept by servers/.test(txt)&&/85\.00/.test(txt) };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('the daily close shows a tip-out breakdown:', out.shown);
+ console.log('each role gets its % of tips (Kitchen 5%=$5, Bussers 10%=$10):', out.kitchen && out.bussers);
+ console.log('servers keep the remainder ($85 of $100):', out.kept);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.shown && out.kitchen && out.bussers && out.kept && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });
diff --git a/tests/upsell.js b/tests/upsell.js
new file mode 100644
index 0000000..4a97c82
--- /dev/null
+++ b/tests/upsell.js
@@ -0,0 +1,44 @@
+const { chromium } = require('playwright-core');
+const http = require('http'), fs = require('fs'), path = require('path');
+const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
+const ROOT = path.resolve(__dirname, '..');
+const TYPES = { '.html':'text/html', '.js':'text/javascript' };
+function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); }
+
+// 💡 SUGGESTED ADD-ON / UPSELL: an item's `suggest` list surfaces one-tap add buttons for complementary items
+// not already on the ticket (Bubba → Fries / Milkshake / Drink). Tapping adds it and drops it from the suggestions.
+(async () => {
+ const errors = [];
+ const server = await serve(); const port = server.address().port;
+ const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] });
+ const ctx = await b.newContext(); const p = await ctx.newPage();
+ p.on('console', m => { if (m.type()==='error') errors.push(m.text()); });
+ p.on('pageerror', e => errors.push('pageerror: '+e.message));
+ await p.goto(`http://127.0.0.1:${port}/builder.html`);
+ await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click();
+ await p.getByRole('button',{name:/Build it for me/}).click();
+ await p.waitForFunction(() => window.__build && window.__build.html);
+
+ const out = await p.evaluate(async () => {
+ const doc=()=>document.querySelector('#preview').contentDocument;
+ const wait=ms=>new Promise(r=>setTimeout(r,ms));
+ const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;};
+ cB(/^Got it/); cB(/^Front Counter/); await wait(180);
+ const bubba=[...doc().querySelectorAll('.tile')].find(x=>/Bubba Burger/.test(x.textContent)); if(bubba)bubba.click(); await wait(100);
+ cB(/^Single/); cB(/Add to order/); await wait(120);
+ const shown=/Want to add/.test(doc().body.innerText);
+ const btns=[...doc().querySelectorAll('button')].map(b=>b.textContent.trim()).filter(x=>x.startsWith('+ ')&&/Fries|Milkshake|Fountain Drink/.test(x));
+ const fb=[...doc().querySelectorAll('button')].find(x=>x.textContent.trim().startsWith('+ Fries')); if(fb)fb.click(); await wait(120);
+ const friesAdded=[...doc().querySelectorAll('.line')].some(l=>/Fries/.test(l.textContent));
+ const friesGone=![...doc().querySelectorAll('button')].some(x=>x.textContent.trim().startsWith('+ Fries'));
+ return { shown, btnCount:btns.length, friesAdded, friesGone };
+ });
+
+ await b.close(); server.close();
+ console.log('\n=== RESULTS ===');
+ console.log('adding a main surfaces its suggested add-ons (Fries/Milkshake/Drink):', out.shown && out.btnCount===3);
+ console.log('tapping a suggestion adds it and drops it from the list:', out.friesAdded && out.friesGone);
+ console.log('console errors:', errors.length?errors:'NONE');
+ const ok = out.shown && out.btnCount===3 && out.friesAdded && out.friesGone && !errors.length;
+ process.exit(ok?0:1);
+})().catch(e => { console.error('FATAL', e); process.exit(2); });