Pulse Bill · Pulse Employee

Manage restaurant accounts

Restaurant Accounts

RestaurantUsernameStatusCreated
Loading...
PI

Pulse Bill

Insights That Drive Impact

Shown on the receipt and PDF bill. PNG/JPG, ideally square.

Customer

Menu & Items

+ Add a custom / one-off item
Manage menu items

Bill Items

ItemQtyPriceTotal
No items added yet
Subtotal₹0.00
Discount−₹0.00
Tax+₹0.00
Total₹0.00
"Generate Bill" saves it to history without downloading a file. "Generate PDF Bill" does the same and also downloads a PDF. "Send via WhatsApp" opens a chat with the bill pre-filled as text — attach the downloaded PDF manually if you'd like to include it.

Bill History

No bills generated yet

Send an Offer

Opens a WhatsApp chat with the offer message ready to send. For sending to many customers automatically, that needs the WhatsApp Business API setup we discussed separately.
Today's Sales
₹0.00
0 bills
Avg Bill Value (Today)
₹0.00
per bill
Last 7 Days
₹0.00
0 bills
Last 30 Days
₹0.00
0 bills

Sales Trend

Sales by Hour (Today)

Top Items (Last 30 Days)

No sales data yet

Item Split (Today)

`; const win = window.open('', '_blank', 'width=480,height=700'); if(!win){ showToast('Please allow pop-ups to print the bill'); return; } win.document.open(); win.document.write(html); win.document.close(); } /* ---------- WhatsApp ---------- */ function sendViaWhatsApp(){ if(billItems.length===0){ showToast('Add at least one item first'); return; } const phone = document.getElementById('custPhone').value.trim().replace(/[^0-9]/g,''); const t = computeTotals(); const rname = document.getElementById('restaurantName').value.trim() || 'the restaurant'; const custName = document.getElementById('custName').value.trim() || 'there'; let msg = `Hi ${custName}, here's your bill from ${rname}:\n\n`; billItems.forEach(b=>{ msg += `${b.name} x${b.qty} — Rs.${(b.price*b.qty).toFixed(2)}\n`; }); msg += `\nSubtotal: Rs.${t.subtotal.toFixed(2)}`; msg += `\nDiscount: -Rs.${t.discountAmt.toFixed(2)}`; msg += `\nTax: +Rs.${t.taxAmt.toFixed(2)}`; msg += `\n*Total: Rs.${t.grandTotal.toFixed(2)}*`; msg += `\n\nThank you for dining with us!`; if(!phone){ showToast('Enter customer WhatsApp number to send directly (opening WhatsApp without a number instead)'); } const url = phone ? `https://wa.me/${phone}?text=${encodeURIComponent(msg)}` : `https://wa.me/?text=${encodeURIComponent(msg)}`; window.open(url, '_blank'); } function sendOffer(){ const phone = document.getElementById('offerPhone').value.trim().replace(/[^0-9]/g,''); const text = document.getElementById('offerText').value.trim(); if(!text){ showToast('Write an offer message first'); return; } if(!phone){ showToast('Enter a WhatsApp number to send the offer to'); return; } const rname = document.getElementById('restaurantName').value.trim() || 'us'; const msg = `Hi! A special message from ${rname}:\n\n${text}`; const url = `https://wa.me/${phone}?text=${encodeURIComponent(msg)}`; window.open(url, '_blank'); } /* ---------- Misc ---------- */ function clearBill(){ if(billItems.length && !confirm('Clear all items from the current bill?')) return; billItems = []; document.getElementById('custName').value=''; document.getElementById('custPhone').value=''; document.getElementById('tableNo').value=''; document.getElementById('discountPct').value=0; document.getElementById('taxPct').value=5; renderBillItems(); } // Used after a successful save (Generate Bill / Generate PDF Bill) to reset the form // for the next customer, without the "are you sure" prompt since it's expected flow. function clearBillFieldsOnly(){ billItems = []; document.getElementById('custName').value=''; document.getElementById('custPhone').value=''; document.getElementById('tableNo').value=''; document.getElementById('discountPct').value=0; document.getElementById('taxPct').value=5; renderBillItems(); } function setOrderNoField(){ const el = document.getElementById('orderNo'); if(el && session && session.restaurant){ el.value = '#' + (session.restaurant.next_order_no || 1); } } function escapeHtml(s){ return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } let toastTimer; function showToast(msg){ const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(()=>t.classList.remove('show'), 2600); } /* ---------- Tabs ---------- */ let trendRange = 7; let trendChartInstance, hourChartInstance, pieChartInstance; function switchTab(tab){ const isBilling = tab==='billing'; document.getElementById('panelBilling').classList.toggle('active', isBilling); document.getElementById('panelDashboard').classList.toggle('active', !isBilling); document.getElementById('tabBtnBilling').classList.toggle('active', isBilling); document.getElementById('tabBtnDashboard').classList.toggle('active', !isBilling); if(!isBilling){ renderDashboard(); } } function setTrendRange(days){ trendRange = days; document.getElementById('rangeBtn7').classList.toggle('active', days===7); document.getElementById('rangeBtn30').classList.toggle('active', days===30); renderTrendChart(); } /* ---------- Dashboard helpers ---------- */ // Uses the reliable ISO timestamp saved with each record. Older records saved // before this field existed fall back to parsing the display string (best-effort). function parseRecordDate(rec){ if(rec.isoDate){ const d = new Date(rec.isoDate); if(!isNaN(d.getTime())) return d; } const d2 = new Date(rec.date); if(!isNaN(d2.getTime())) return d2; return new Date(0); // unparseable old record — treat as far past rather than "now" } function isSameDay(a,b){ return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); } function daysAgo(n){ const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate()-n); return d; } function renderDashboard(){ const hist = loadHistory(); const now = new Date(); const todayBills = hist.filter(r => isSameDay(parseRecordDate(r), now)); const todaySales = todayBills.reduce((s,r)=>s+r.total,0); const sevenCutoff = daysAgo(6); // includes today => last 7 days const sevenBills = hist.filter(r => parseRecordDate(r) >= sevenCutoff); const sevenSales = sevenBills.reduce((s,r)=>s+r.total,0); const thirtyCutoff = daysAgo(29); const thirtyBills = hist.filter(r => parseRecordDate(r) >= thirtyCutoff); const thirtySales = thirtyBills.reduce((s,r)=>s+r.total,0); document.getElementById('statTodaySales').textContent = '₹'+todaySales.toFixed(2); document.getElementById('statTodayBills').textContent = todayBills.length + (todayBills.length===1?' bill':' bills'); document.getElementById('statAvgBill').textContent = '₹'+(todayBills.length? (todaySales/todayBills.length):0).toFixed(2); document.getElementById('stat7Days').textContent = '₹'+sevenSales.toFixed(2); document.getElementById('stat7DaysBills').textContent = sevenBills.length + (sevenBills.length===1?' bill':' bills'); document.getElementById('stat30Days').textContent = '₹'+thirtySales.toFixed(2); document.getElementById('stat30DaysBills').textContent = thirtyBills.length + (thirtyBills.length===1?' bill':' bills'); renderTrendChart(); renderHourChart(todayBills); renderTopItems(thirtyBills); renderItemPie(todayBills); } function renderTrendChart(){ const hist = loadHistory(); const days = []; for(let i=trendRange-1; i>=0; i--){ days.push(daysAgo(i)); } const labels = days.map(d => d.toLocaleDateString('en-IN',{day:'2-digit',month:'short'})); const totals = days.map(d => { return hist.filter(r=>isSameDay(parseRecordDate(r), d)).reduce((s,r)=>s+r.total,0); }); const ctx = document.getElementById('trendChart').getContext('2d'); if(trendChartInstance) trendChartInstance.destroy(); trendChartInstance = new Chart(ctx, { type:'line', data:{ labels, datasets:[{ label:'Sales (₹)', data:totals, borderColor:'#1f6f4f', backgroundColor:'rgba(31,111,79,0.12)', fill:true, tension:0.3, pointBackgroundColor:'#c8a951', pointRadius:4, borderWidth:2.5 }] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{display:false} }, scales:{ y:{ beginAtZero:true, ticks:{ callback:v=>'₹'+v } }, x:{ grid:{display:false} } } } }); } function renderHourChart(todayBills){ const hourly = new Array(24).fill(0); todayBills.forEach(r=>{ const h = parseRecordDate(r).getHours(); hourly[h] += r.total; }); // Only show hours with any activity range, e.g. 8am - 11pm, to avoid a wall of empty bars const startHour = 8, endHour = 23; const labels = []; const data = []; for(let h=startHour; h<=endHour; h++){ labels.push((h%12===0?12:h%12) + (h<12?'am':'pm')); data.push(hourly[h]); } const ctx = document.getElementById('hourChart').getContext('2d'); if(hourChartInstance) hourChartInstance.destroy(); hourChartInstance = new Chart(ctx, { type:'bar', data:{ labels, datasets:[{ label:'Sales (₹)', data, backgroundColor:'#2c8f66', borderRadius:4 }] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{display:false} }, scales:{ y:{ beginAtZero:true, ticks:{ callback:v=>'₹'+v } }, x:{ grid:{display:false} } } } }); } function renderTopItems(bills){ const counts = {}; bills.forEach(r=>{ (r.items||[]).forEach(it=>{ if(!counts[it.name]) counts[it.name] = {qty:0, revenue:0}; counts[it.name].qty += it.qty; counts[it.name].revenue += it.qty*it.price; }); }); const ranked = Object.entries(counts).sort((a,b)=>b[1].revenue - a[1].revenue).slice(0,8); const el = document.getElementById('topItemsList'); if(ranked.length===0){ el.innerHTML = '
No sales data yet
'; return; } el.innerHTML = ranked.map(([name,d],i)=>`
${i+1}
${escapeHtml(name)}
${d.qty} sold
₹${d.revenue.toFixed(2)}
`).join(''); } function renderItemPie(todayBills){ const counts = {}; todayBills.forEach(r=>{ (r.items||[]).forEach(it=>{ counts[it.name] = (counts[it.name]||0) + it.qty*it.price; }); }); const entries = Object.entries(counts).sort((a,b)=>b[1]-a[1]); const top = entries.slice(0,5); const otherTotal = entries.slice(5).reduce((s,e)=>s+e[1],0); const labels = top.map(e=>e[0]); const data = top.map(e=>e[1]); if(otherTotal>0){ labels.push('Other'); data.push(otherTotal); } const palette = ['#1f6f4f','#2c8f66','#c8a951','#e0c26f','#16324a','#8b9797']; const ctx = document.getElementById('itemPieChart').getContext('2d'); if(pieChartInstance) pieChartInstance.destroy(); if(labels.length===0){ pieChartInstance = new Chart(ctx, { type:'doughnut', data:{labels:['No sales today'], datasets:[{data:[1], backgroundColor:['#e6ebe6']}]}, options:{plugins:{legend:{position:'bottom', labels:{boxWidth:10, font:{size:11}}}}} }); return; } pieChartInstance = new Chart(ctx, { type:'doughnut', data:{ labels, datasets:[{ data, backgroundColor:palette }] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{position:'bottom', labels:{boxWidth:10, font:{size:11}}} } } }); } /* ================= Pulse Employee panel ================= */ let restaurantsCache = []; async function refreshRestaurantsList(){ const tbody = document.getElementById('saRestaurantsBody'); try{ const data = await apiFetch('/api/superadmin/restaurants'); restaurantsCache = data.restaurants || []; renderRestaurantsList(); } catch(e){ tbody.innerHTML = `Could not load: ${escapeHtml(e.message)}`; } } function renderRestaurantsList(){ const tbody = document.getElementById('saRestaurantsBody'); if(restaurantsCache.length===0){ tbody.innerHTML = 'No restaurant accounts yet'; return; } tbody.innerHTML = restaurantsCache.map(r => ` ${escapeHtml(r.restaurant_name)} ${escapeHtml(r.username)} ${r.is_active ? 'Active':'Disabled'} ${new Date(r.created_at).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'})}
`).join(''); } function openCreateRestaurantModal(){ document.getElementById('newRestName').value = ''; document.getElementById('newRestUsername').value = ''; document.getElementById('newRestPassword').value = ''; document.getElementById('newRestContact').value = ''; document.getElementById('createRestaurantError').classList.remove('show'); document.getElementById('createRestaurantModal').classList.add('show'); } function closeCreateRestaurantModal(){ document.getElementById('createRestaurantModal').classList.remove('show'); } async function submitCreateRestaurant(){ const restaurant_name = document.getElementById('newRestName').value.trim(); const username = document.getElementById('newRestUsername').value.trim(); const password = document.getElementById('newRestPassword').value; const contact = document.getElementById('newRestContact').value.trim(); const errEl = document.getElementById('createRestaurantError'); errEl.classList.remove('show'); if(!restaurant_name || !username || !password){ errEl.textContent = 'Restaurant name, username, and password are required'; errEl.classList.add('show'); return; } if(password.length < 6){ errEl.textContent = 'Password must be at least 6 characters'; errEl.classList.add('show'); return; } try{ await apiFetch('/api/superadmin/restaurants', { method:'POST', body: JSON.stringify({username, password, restaurant_name, contact}) }); closeCreateRestaurantModal(); await refreshRestaurantsList(); showToast('Restaurant account created'); } catch(e){ errEl.textContent = e.message; errEl.classList.add('show'); } } async function toggleRestaurantActive(id, makeActive){ try{ await apiFetch('/api/superadmin/restaurants/' + id, { method:'PATCH', body: JSON.stringify({ is_active: makeActive === 'true' || makeActive === true }) }); await refreshRestaurantsList(); } catch(e){ showToast('Could not update account: ' + e.message); } } let resetPasswordTargetId = null; function openResetPasswordModal(id){ resetPasswordTargetId = id; document.getElementById('resetPasswordValue').value = ''; document.getElementById('resetPasswordError').classList.remove('show'); document.getElementById('resetPasswordModal').classList.add('show'); } function closeResetPasswordModal(){ document.getElementById('resetPasswordModal').classList.remove('show'); } async function submitResetPassword(){ const pw = document.getElementById('resetPasswordValue').value; const errEl = document.getElementById('resetPasswordError'); if(!pw || pw.length < 6){ errEl.textContent = 'Password must be at least 6 characters'; errEl.classList.add('show'); return; } try{ await apiFetch('/api/superadmin/restaurants/' + resetPasswordTargetId, { method:'PATCH', body: JSON.stringify({ new_password: pw }) }); closeResetPasswordModal(); showToast('Password updated'); } catch(e){ errEl.textContent = e.message; errEl.classList.add('show'); } } async function deleteRestaurantAccount(id, name){ if(!confirm(`Delete "${name}"? This permanently removes their account, menu, and bill history. This cannot be undone.`)) return; try{ await apiFetch('/api/superadmin/restaurants/' + id, { method:'DELETE' }); await refreshRestaurantsList(); showToast('Restaurant account deleted'); } catch(e){ showToast('Could not delete account: ' + e.message); } } /* ---------- Boot ---------- */ boot();