cafe / public /js /admin.js
3v324v23's picture
💰 Convert all prices to ₹ (rupees)
6c1a79a
Raw
History Blame Contribute Delete
25.2 kB
// ── Admin Dashboard — The Roasted Bean ─────────────────────────────────────
let menuData = null;
let editingItemId = null;
document.addEventListener('DOMContentLoaded', () => checkAuth());
// ── Auth ────────────────────────────────────────────────────────────────────
async function checkAuth() {
try {
const res = await fetch('/api/auth');
const data = await res.json();
data.authenticated ? showDashboard() : showLogin();
} catch { showLogin(); }
}
function showLogin() {
document.getElementById('loginScreen').style.display = '';
document.getElementById('dashboard').style.display = 'none';
document.getElementById('loginForm').addEventListener('submit', handleLogin);
}
function showDashboard() {
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('dashboard').style.display = '';
setupDashboard();
}
async function handleLogin(e) {
e.preventDefault();
const pw = document.getElementById('password').value;
const errEl = document.getElementById('loginError');
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
const data = await res.json();
if (data.success) { errEl.textContent = ''; showDashboard(); }
else { errEl.textContent = data.error || 'Invalid password'; }
} catch { errEl.textContent = 'Connection error. Server running?'; }
}
// ── Dashboard Setup ─────────────────────────────────────────────────────────
async function setupDashboard() {
await loadMenuData();
setupNav();
setupModal();
setupForms();
showSection('menu');
document.getElementById('logoutBtn').addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
location.reload();
});
document.getElementById('addItemBtn').addEventListener('click', () => openModal());
}
async function loadMenuData() {
const res = await fetch('/api/menu');
menuData = await res.json();
}
// ── Navigation ──────────────────────────────────────────────────────────────
function setupNav() {
document.getElementById('topbarNav').addEventListener('click', (e) => {
const btn = e.target.closest('.tb-nav-item');
if (!btn) return;
document.querySelectorAll('.tb-nav-item').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
showSection(btn.dataset.section);
});
}
function showSection(section) {
document.querySelectorAll('.panel').forEach(p => p.style.display = 'none');
switch (section) {
case 'menu':
document.getElementById('menuSection').style.display = '';
renderTable();
break;
case 'categories':
document.getElementById('categoriesSection').style.display = '';
renderCategories();
break;
case 'info':
document.getElementById('infoSection').style.display = '';
renderInfo();
break;
case 'tables':
document.getElementById('tablesSection').style.display = '';
setupTablesSection();
break;
case 'settings':
document.getElementById('settingsSection').style.display = '';
break;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
function renderTable() {
const tbody = document.getElementById('menuTableBody');
const empty = document.getElementById('menuEmpty');
const items = menuData?.items || [];
if (items.length === 0) { tbody.innerHTML = ''; empty.style.display = ''; return; }
empty.style.display = 'none';
const cats = {};
(menuData.categories || []).forEach(c => { cats[c.id] = c.name; });
tbody.innerHTML = items.map(item => {
const hasImg = item.image && item.image.startsWith('http');
const thumbImg = hasImg
? `<img src="${esc(item.image)}" alt="" loading="lazy" onerror="this.style.display='none'">`
: '';
const thumbSvg = adminSvgIcon(item.category);
return `
<tr>
<td>
<div class="item-cell">
<span class="item-thumb">${thumbSvg}${thumbImg}</span>
<div>
<div class="item-name">${esc(item.name)}</div>
<div class="item-desc">${esc((item.description || '').slice(0, 50))}</div>
</div>
</div>
</td>
<td>${cats[item.category] || item.category}</td>
<td>₹${item.price.toFixed(0)}</td>
<td>
<span class="badge ${item.available ? 'badge-available' : 'badge-soldout'}">
${item.available ? 'Available' : 'Sold out'}
</span>
</td>
<td>
<div class="actions">
<button class="act edit" data-id="${item.id}" title="Edit">✎</button>
<button class="act toggle" data-id="${item.id}" title="${item.available ? 'Mark sold out' : 'Mark available'}">${item.available ? '⊘' : '✓'}</button>
<button class="act danger" data-id="${item.id}" title="Delete">✕</button>
</div>
</td>
</tr>
`}).join('');
tbody.addEventListener('click', (e) => {
const btn = e.target.closest('.act');
if (!btn) return;
const id = btn.dataset.id;
const item = items.find(i => i.id === id);
if (!item) return;
if (btn.classList.contains('edit')) openModal(item);
else if (btn.classList.contains('toggle')) toggleItem(item);
else if (btn.classList.contains('danger')) deleteItem(id);
});
}
// ── Modal ───────────────────────────────────────────────────────────────────
function setupModal() {
const modal = document.getElementById('itemModal');
document.getElementById('modalClose').addEventListener('click', closeModal);
document.getElementById('modalCancel').addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
document.getElementById('itemForm').addEventListener('submit', saveItem);
}
function openModal(item = null) {
editingItemId = item?.id || null;
document.getElementById('modalTitle').textContent = item ? 'Edit item' : 'Add item';
document.getElementById('modalSave').textContent = item ? 'Update' : 'Add';
const select = document.getElementById('itemCategory');
select.innerHTML = (menuData.categories || [])
.filter(c => c.id !== 'all')
.map(c => `<option value="${c.id}">${c.name}</option>`)
.join('');
document.getElementById('itemId').value = item?.id || '';
document.getElementById('itemName').value = item?.name || '';
document.getElementById('itemPrice').value = item?.price || '';
document.getElementById('itemCategory').value = item?.category || '';
document.getElementById('itemDescription').value = item?.description || '';
document.getElementById('itemImage').value = (item?.image && item.image.startsWith('http')) ? item.image : '';
document.getElementById('itemAvailable').checked = item ? item.available : true;
document.getElementById('itemFeatured').checked = item ? item.featured : false;
document.getElementById('itemModal').classList.add('open');
setTimeout(() => document.getElementById('itemName').focus(), 150);
}
function closeModal() {
document.getElementById('itemModal').classList.remove('open');
editingItemId = null;
document.getElementById('itemForm').reset();
}
async function saveItem(e) {
e.preventDefault();
const id = document.getElementById('itemId').value;
const item = {
name: document.getElementById('itemName').value.trim(),
price: parseFloat(document.getElementById('itemPrice').value),
category: document.getElementById('itemCategory').value,
description: document.getElementById('itemDescription').value.trim(),
image: document.getElementById('itemImage').value.trim() || null,
available: document.getElementById('itemAvailable').checked,
featured: document.getElementById('itemFeatured').checked
};
if (!item.name || isNaN(item.price) || !item.category) {
return showToast('Fill in name, price, and category.', 'error');
}
try {
const url = id ? `/api/menu/${id}` : '/api/menu';
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
});
if (!res.ok) throw new Error((await res.json()).error || 'Failed');
await loadMenuData();
renderTable();
closeModal();
showToast(id ? 'Item updated.' : 'Item added.', 'success');
} catch (err) {
showToast(err.message || 'Error saving', 'error');
}
}
async function toggleItem(item) {
try {
const res = await fetch(`/api/menu/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ available: !item.available })
});
if (!res.ok) throw new Error('Failed');
await loadMenuData();
renderTable();
showToast(`Marked as ${!item.available ? 'available' : 'sold out'}.`, 'success');
} catch { showToast('Error updating', 'error'); }
}
async function deleteItem(id) {
if (!confirm('Delete this item? This cannot be undone.')) return;
try {
const res = await fetch(`/api/menu/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed');
await loadMenuData();
renderTable();
showToast('Item deleted.', 'success');
} catch { showToast('Error deleting', 'error'); }
}
// ── Categories ──────────────────────────────────────────────────────────────
function renderCategories() {
const container = document.getElementById('categoriesEditor');
const cats = menuData?.categories || [];
container.innerHTML = cats.map((cat, i) => `
<div class="cat-row">
<input type="text" class="cat-icon" value="${esc(cat.icon || '')}" data-index="${i}" data-field="icon" placeholder="Icon">
<input type="text" class="cat-name" value="${esc(cat.name || '')}" data-index="${i}" data-field="name" placeholder="Name">
<button type="button" class="cat-remove" data-index="${i}">✕</button>
</div>
`).join('');
container.querySelectorAll('.cat-remove').forEach(btn => {
btn.addEventListener('click', () => {
menuData.categories.splice(parseInt(btn.dataset.index), 1);
renderCategories();
});
});
document.getElementById('addCategoryBtn').onclick = () => {
menuData.categories.push({ id: '', name: '', icon: '📌' });
renderCategories();
};
document.getElementById('saveCategoriesBtn').onclick = async () => {
const rows = container.querySelectorAll('.cat-row');
const categories = [];
rows.forEach(row => {
const icon = row.querySelector('.cat-icon').value.trim();
const name = row.querySelector('.cat-name').value.trim();
if (name) categories.push({ id: name.toLowerCase().replace(/\s+/g, '-'), name, icon: icon || '📌' });
});
try {
const res = await fetch('/api/categories', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categories })
});
if (!res.ok) throw new Error('Failed');
await loadMenuData();
renderCategories();
showToast('Categories saved.', 'success');
} catch { showToast('Error saving', 'error'); }
};
}
// ── Cafe Info ───────────────────────────────────────────────────────────────
function renderInfo() {
const info = menuData?.info || {};
document.getElementById('infoName').value = info.name || '';
document.getElementById('infoTagline').value = info.tagline || '';
document.getElementById('infoAddress').value = info.address || '';
document.getElementById('infoPhone').value = info.phone || '';
document.getElementById('infoEmail').value = info.email || '';
const hours = info.hours || [];
const editor = document.getElementById('hoursEditor');
editor.innerHTML = hours.map((h, i) => `
<div class="hours-row">
<input type="text" class="hours-days" value="${esc(h.days || '')}" data-index="${i}" data-field="days" placeholder="e.g. Mon—Fri">
<input type="text" class="hours-time" value="${esc(h.time || '')}" data-index="${i}" data-field="time" placeholder="7:00 AM — 7:00 PM">
<button type="button" class="cat-remove hours-del" data-index="${i}">✕</button>
</div>
`).join('');
editor.querySelectorAll('.hours-del').forEach(btn => {
btn.addEventListener('click', () => {
menuData.info.hours.splice(parseInt(btn.dataset.index), 1);
renderInfo();
});
});
document.getElementById('addHoursBtn').onclick = () => {
if (!menuData.info.hours) menuData.info.hours = [];
menuData.info.hours.push({ days: '', time: '' });
renderInfo();
};
}
// ── Forms ───────────────────────────────────────────────────────────────────
function setupForms() {
document.getElementById('infoForm').addEventListener('submit', async (e) => {
e.preventDefault();
const rows = document.querySelectorAll('#hoursEditor .hours-row');
const hours = [];
rows.forEach(row => {
const days = row.querySelector('.hours-days').value.trim();
const time = row.querySelector('.hours-time').value.trim();
if (days) hours.push({ days, time });
});
const info = {
name: document.getElementById('infoName').value.trim(),
tagline: document.getElementById('infoTagline').value.trim(),
address: document.getElementById('infoAddress').value.trim(),
phone: document.getElementById('infoPhone').value.trim(),
email: document.getElementById('infoEmail').value.trim(),
hours
};
try {
const res = await fetch('/api/info', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info)
});
if (!res.ok) throw new Error('Failed');
await loadMenuData();
showToast('Cafe info saved.', 'success');
} catch { showToast('Error saving', 'error'); }
});
document.getElementById('passwordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errEl = document.getElementById('passwordError');
const okEl = document.getElementById('passwordSuccess');
errEl.textContent = '';
okEl.textContent = '';
const current = document.getElementById('currentPassword').value;
const newPw = document.getElementById('newPassword').value;
const confirm = document.getElementById('confirmPassword').value;
if (newPw !== confirm) { errEl.textContent = 'Passwords do not match.'; return; }
if (newPw.length < 6) { errEl.textContent = 'At least 6 characters.'; return; }
try {
const check = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: current })
});
if (!(await check.json()).success) {
errEl.textContent = 'Current password is incorrect.';
return;
}
const res = await fetch('/api/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword: newPw })
});
const data = await res.json();
if (data.success) {
okEl.textContent = 'Password updated.';
document.getElementById('passwordForm').reset();
} else {
errEl.textContent = data.error || 'Error updating.';
}
} catch { errEl.textContent = 'Connection error.'; }
});
}
// ── Tables & Orders ────────────────────────────────────────────────────────
let tablesSetup = false;
function setupTablesSection() {
if (tablesSetup) { renderOrders(); return; }
tablesSetup = true;
document.getElementById('saveTablesBtn').addEventListener('click', saveTableCount);
document.getElementById('loadQrBtn').addEventListener('click', loadQrCodes);
document.getElementById('refreshOrdersBtn').addEventListener('click', () => { renderOrders(); });
// Set current table count
document.getElementById('tableCount').value = menuData.tables || 0;
renderOrders();
}
async function saveTableCount() {
const count = parseInt(document.getElementById('tableCount').value);
if (isNaN(count) || count < 0 || count > 100) {
return showToast('Enter 0–100', 'error');
}
try {
const res = await fetch('/api/tables', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count })
});
if (!res.ok) throw new Error('Failed');
menuData.tables = count;
showToast(`Saved: ${count} tables`, 'success');
} catch { showToast('Error saving', 'error'); }
}
async function loadQrCodes() {
const grid = document.getElementById('qrGrid');
grid.innerHTML = '<p style="color:var(--parchment);font-size:0.82rem;padding:20px;">Generating QR codes…</p>';
try {
const res = await fetch('/api/qr');
if (!res.ok) throw new Error('Failed');
const qrs = await res.json();
if (qrs.length === 0) {
grid.innerHTML = '<p style="color:var(--parchment);font-size:0.82rem;padding:32px;">No tables configured. Set the number above first.</p>';
return;
}
grid.innerHTML = qrs.map(q => `
<div class="qr-card">
<img src="${q.dataUrl}" alt="QR for Table ${q.table}" width="150" height="150">
<div class="qr-label">Table ${q.table}</div>
<div class="qr-url">${esc(q.url)}</div>
</div>
`).join('');
showToast(`${qrs.length} QR codes ready for print`, 'success');
} catch (err) {
console.error(err);
grid.innerHTML = '<p style="color:var(--cherry);font-size:0.82rem;padding:32px;">Failed to generate QR codes.</p>';
}
}
async function renderOrders() {
const container = document.getElementById('ordersContainer');
try {
const res = await fetch('/api/orders');
if (!res.ok) throw new Error('Failed');
const orders = await res.json();
// Only show active orders (hide cancelled & paid)
const activeOrders = orders.filter(o => o.status === 'pending' || o.status === 'served');
if (activeOrders.length === 0) {
container.innerHTML = '<p style="color:var(--parchment);font-size:0.82rem;padding:16px 0;">No active orders.</p>';
return;
}
const statusLabel = { pending: 'New', served: 'Served', paid: 'Paid', cancelled: 'Cancelled' };
const statusClass = { pending: 'badge-order-new', served: 'badge-order-served', paid: 'badge-order-paid', cancelled: 'badge-order-cancelled' };
container.innerHTML = activeOrders.map(o => `
<div class="order-card">
<div class="order-card-head">
<div>
<span class="order-tbl">Table ${o.table}</span>
<span class="order-time">${timeAgo(o.createdAt)}</span>
</div>
<span class="badge ${statusClass[o.status] || ''}">${statusLabel[o.status] || o.status}</span>
</div>
<div class="order-card-body">
<ul class="order-items">
${o.items.map(i => i.mid
? `<li>${i.qty}× ${esc(i.name)} <span>₹${(i.price * i.qty).toFixed(0)}</span></li>`
: `<li class="order-item-custom">"${esc(i.text)}"</li>`
).join('')}
</ul>
${o.note ? `<p class="order-note">📝 ${esc(o.note)}</p>` : ''}
<p class="order-total">Total: ₹${o.total.toFixed(0)}</p>
</div>
${`
<div class="order-card-actions">
${o.status === 'pending' ? `<button class="btn act-order serve" data-id="${o.id}" data-status="served">✓ Served</button>` : ''}
${o.status === 'served' ? `<button class="btn act-order paid" data-id="${o.id}" data-status="paid">$ Paid</button>` : ''}
${o.status !== 'paid' ? `<button class="btn btn-ghost act-order cancel" data-id="${o.id}" data-status="cancelled">✕ Cancel</button>` : ''}
</div>
`}
</div>
`).join('');
// Wire up action buttons
container.querySelectorAll('.act-order').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const status = btn.dataset.status;
try {
const res = await fetch(`/api/orders/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!res.ok) throw new Error('Failed');
showToast(`Order ${status}`, 'success');
renderOrders();
} catch { showToast('Error updating', 'error'); }
});
});
} catch (err) {
console.error(err);
container.innerHTML = '<p style="color:var(--cherry);">Failed to load orders.</p>';
}
}
function timeAgo(iso) {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
const d = new Date(iso);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
// ── Toast ───────────────────────────────────────────────────────────────────
function showToast(msg, type = 'success') {
const container = document.getElementById('toastContainer');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `<span>${type === 'success' ? '✓' : '✗'}</span> ${msg}`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.25s';
setTimeout(() => toast.remove(), 250);
}, 2800);
}
// ── Utils ───────────────────────────────────────────────────────────────────
function esc(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function adminSvgIcon(category) {
const svgs = {
'coffee': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h14v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"/><path d="M18 9h2a2 2 0 012 2v1a2 2 0 01-2 2h-2"/><ellipse cx="8" cy="14" rx="2" ry="1.5"/><path d="M8 1v2" opacity="0.4"/><path d="M6 0v2" opacity="0.3"/><path d="M10 0v2" opacity="0.3"/></svg>',
'tea': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8h10a3 3 0 013 3v3a3 3 0 01-3 3H5a3 3 0 01-3-3v-3a3 3 0 013-3z"/><path d="M2 11l-2 3h2l1-3"/><path d="M7 8V6a1 1 0 011-1h2a1 1 0 011 1v2"/><circle cx="10" cy="3.5" r="1.2"/><path d="M8 1v2" opacity="0.4"/><path d="M10 0v2" opacity="0.3"/><path d="M12 1v2" opacity="0.3"/></svg>',
'pastries': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 16C4 16 6 10 12 8s10 0 10 0c-4 2-6 8-6 8"/><path d="M5 14c1-3 4-6 7-7"/><path d="M19 14c-1-3-4-6-7-7"/><path d="M8 10h8" opacity="0.3"/></svg>',
'food': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="18" rx="10" ry="3"/><rect x="8" y="8" width="8" height="7" rx="1"/><line x1="10" y1="9.5" x2="14" y2="9.5" opacity="0.4"/><line x1="10" y1="11.5" x2="14" y2="11.5" opacity="0.3"/><ellipse cx="12" cy="16" rx="3" ry="2" fill="currentColor" opacity="0.15"/></svg>'
};
return svgs[category] || svgs['coffee'];
}