| window.SmOSAdmin = (function () { |
| function moduleMeta() { |
| if (window.SmOS_Module) return window.SmOS_Module; |
| const match = location.pathname.match(/\/modules\/([^/]+)\/admin/); |
| return { |
| id: match ? match[1] : "SmOS_CC", |
| name: "The Corner Cafe", |
| }; |
| } |
|
|
| function moduleId() { |
| return moduleMeta().id; |
| } |
|
|
| function tokenKey() { |
| return `smos-admin-token-${moduleId()}`; |
| } |
|
|
| function adminApiBase() { |
| return `/api/modules/${moduleId()}/admin`; |
| } |
|
|
| function loginPath() { |
| const base = location.pathname.replace(/\/[^/]*$/, "/"); |
| return base.endsWith("/admin/") ? base : `${base.replace(/\/?$/, "/")}`; |
| } |
|
|
| const API_FALLBACK = "http://127.0.0.1:7860"; |
|
|
| const SERVICE_ORDER = ["delivery", "collection", "sitting-in", "reservation"]; |
| const SERVICE_LABELS = { |
| delivery: "Delivery", |
| collection: "Collection", |
| "sitting-in": "Sit-in", |
| reservation: "Reserve", |
| }; |
|
|
| function apiBases() { |
| const list = []; |
| if (location.origin && location.origin.startsWith("http")) list.push(location.origin); |
| if (!list.includes(API_FALLBACK)) list.push(API_FALLBACK); |
| return list; |
| } |
|
|
| function token() { |
| try { |
| return localStorage.getItem(tokenKey()) || ""; |
| } catch (e) { |
| return ""; |
| } |
| } |
|
|
| function setToken(value) { |
| try { |
| if (value) localStorage.setItem(tokenKey(), value); |
| else localStorage.removeItem(tokenKey()); |
| } catch (e) { |
| |
| } |
| } |
|
|
| async function api(path, options = {}) { |
| const headers = { ...(options.headers || {}) }; |
| if (token()) headers.Authorization = `Bearer ${token()}`; |
| if (options.body && !headers["Content-Type"]) { |
| headers["Content-Type"] = "application/json"; |
| } |
| let lastErr = null; |
| for (const base of apiBases()) { |
| try { |
| const res = await fetch(`${base}${path}`, { ...options, headers }); |
| if (res.status === 401) { |
| setToken(""); |
| const onLogin = |
| /\/admin\/?$/.test(location.pathname) || |
| /\/admin\/index\.html$/.test(location.pathname); |
| if (!onLogin) window.location.href = loginPath(); |
| throw new Error("Unauthorized"); |
| } |
| if (res.ok) return res.status === 204 ? null : res.json(); |
| const err = await res.json().catch(() => ({})); |
| lastErr = new Error(err.detail || `HTTP ${res.status}`); |
| } catch (e) { |
| if (e.message === "Unauthorized") throw e; |
| lastErr = e; |
| } |
| } |
| throw lastErr || new Error("API unavailable"); |
| } |
|
|
| function money(n) { |
| if (n == null || Number.isNaN(Number(n))) return "—"; |
| return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(n)); |
| } |
|
|
| function esc(s) { |
| return String(s ?? "") |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """); |
| } |
|
|
| function serviceLabel(s) { |
| return SERVICE_LABELS[s] || s || "—"; |
| } |
|
|
| function payState(b) { |
| const raw = (b.payment_status || "").toLowerCase(); |
| if (raw && ["paid", "awaiting", "unpaid", "failed", "n/a"].includes(raw)) return raw; |
| if (b.kind === "reservation") return "n/a"; |
| const method = String((b.payload && b.payload.payment) || b.payment || "").toLowerCase(); |
| if (method.includes("transfer") || method === "bank-transfer") return "awaiting"; |
| return "unpaid"; |
| } |
|
|
| function payMeta(state, service) { |
| const map = { |
| paid: { label: "Paid", tone: "paid" }, |
| awaiting: { label: "Await", tone: "awaiting" }, |
| unpaid: { |
| label: service === "sitting-in" ? "Settle at till" : "Unpaid", |
| tone: "unpaid", |
| }, |
| failed: { label: "Failed", tone: "failed" }, |
| "n/a": { label: "On visit", tone: "na" }, |
| }; |
| return map[state] || map.unpaid; |
| } |
|
|
| function itemsLine(b, max = 3) { |
| const items = Array.isArray(b.items) ? b.items : []; |
| if (!items.length) { |
| if (b.kind === "reservation") { |
| const seats = b.party_size ? `${b.party_size}p` : ""; |
| const tables = Array.isArray(b.tables) |
| ? b.tables.map((t) => t.label || t.id).filter(Boolean).join("+") |
| : ""; |
| return [seats, tables ? `T${tables}` : ""].filter(Boolean).join(" · ") || "Reserve"; |
| } |
| return "—"; |
| } |
| const parts = items.map((i) => `${i.qty || 1}×${i.name || "Item"}`); |
| if (parts.length <= max) return parts.join(", "); |
| return `${parts.slice(0, max).join(", ")} +${parts.length - max}`; |
| } |
|
|
| function tableLine(b) { |
| const tables = Array.isArray(b.tables) ? b.tables : []; |
| if (tables.length) { |
| return tables.map((t) => t.label || t.id).filter(Boolean).join(", "); |
| } |
| const payload = b.payload && typeof b.payload === "object" ? b.payload : {}; |
| return payload.tableId || payload.table_id || ""; |
| } |
|
|
| function locationLine(b) { |
| if (b.service === "delivery") return b.address || "No addr"; |
| if (b.service === "collection") return "Collect"; |
| if (b.service === "sitting-in") { |
| const table = tableLine(b); |
| return table ? `T${table}` : "T—"; |
| } |
| if (b.service === "reservation") { |
| const win = b.arrival_window ? String(b.arrival_window).replace("-", "–") : ""; |
| const when = [b.arrival_date, win].filter(Boolean).join(" "); |
| const table = tableLine(b); |
| return [when || "—", table ? `T${table}` : ""].filter(Boolean).join(" · "); |
| } |
| return b.address || "—"; |
| } |
|
|
| function idLabel(b) { |
| if (b.kind === "reservation" || b.service === "reservation") return "RES"; |
| if (b.service === "delivery") return "DEL"; |
| if (b.service === "collection") return "COL"; |
| if (b.service === "sitting-in") return "SIT"; |
| return "ORD"; |
| } |
|
|
| function fmtWhen(row) { |
| if (row.kind === "reservation") return ""; |
| const d = row.created_at ? new Date(row.created_at) : null; |
| return d && !Number.isNaN(d.getTime()) |
| ? d.toLocaleString("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }) |
| : ""; |
| } |
|
|
| function contactLine(b) { |
| return [b.phone, b.email].filter(Boolean).join(" · "); |
| } |
|
|
| function startOfDay(d) { |
| const x = new Date(d); |
| x.setHours(0, 0, 0, 0); |
| return x; |
| } |
|
|
| function endOfDay(d) { |
| const x = new Date(d); |
| x.setHours(23, 59, 59, 999); |
| return x; |
| } |
|
|
| function startOfWeek(d) { |
| const x = startOfDay(d); |
| const day = x.getDay(); |
| const diff = day === 0 ? -6 : 1 - day; |
| x.setDate(x.getDate() + diff); |
| return x; |
| } |
|
|
| function startOfMonth(d) { |
| const x = startOfDay(d); |
| x.setDate(1); |
| return x; |
| } |
|
|
| function addDays(d, n) { |
| const x = new Date(d); |
| x.setDate(x.getDate() + n); |
| return x; |
| } |
|
|
| function recordDate(b) { |
| if (b.kind === "reservation" && b.arrival_date) { |
| const d = new Date(`${b.arrival_date}T12:00:00`); |
| if (!Number.isNaN(d.getTime())) return d; |
| } |
| const d = b.created_at ? new Date(b.created_at) : null; |
| return d && !Number.isNaN(d.getTime()) ? d : null; |
| } |
|
|
| function periodBounds(period, fromStr, toStr) { |
| const now = new Date(); |
| if (period === "all" || !period) return null; |
| if (period === "today") return { from: startOfDay(now), to: endOfDay(now) }; |
| if (period === "yesterday") { |
| const y = addDays(startOfDay(now), -1); |
| return { from: y, to: endOfDay(y) }; |
| } |
| if (period === "week") { |
| const from = startOfWeek(now); |
| return { from, to: endOfDay(addDays(from, 6)) }; |
| } |
| if (period === "last-week") { |
| const thisWeek = startOfWeek(now); |
| const from = addDays(thisWeek, -7); |
| return { from, to: endOfDay(addDays(from, 6)) }; |
| } |
| if (period === "month") { |
| const from = startOfMonth(now); |
| const to = endOfDay(new Date(now.getFullYear(), now.getMonth() + 1, 0)); |
| return { from, to }; |
| } |
| if (period === "last-month") { |
| const from = startOfMonth(new Date(now.getFullYear(), now.getMonth() - 1, 1)); |
| const to = endOfDay(new Date(now.getFullYear(), now.getMonth(), 0)); |
| return { from, to }; |
| } |
| if (period === "custom") { |
| if (!fromStr && !toStr) return null; |
| const from = fromStr ? startOfDay(new Date(`${fromStr}T00:00:00`)) : null; |
| const to = toStr ? endOfDay(new Date(`${toStr}T00:00:00`)) : null; |
| if (from && Number.isNaN(from.getTime())) return null; |
| if (to && Number.isNaN(to.getTime())) return null; |
| return { from, to }; |
| } |
| return null; |
| } |
|
|
| const STATUS_RANK = { |
| new: 0, |
| confirmed: 1, |
| preparing: 2, |
| ready: 3, |
| completed: 4, |
| cancelled: 5, |
| }; |
| const PAY_RANK = { failed: 0, unpaid: 1, awaiting: 2, paid: 3, "n/a": 4 }; |
|
|
| function sortBookings(list, sortBy) { |
| const rows = list.slice(); |
| const cmpStr = (a, b) => String(a || "").localeCompare(String(b || ""), "en", { sensitivity: "base" }); |
| rows.sort((a, b) => { |
| switch (sortBy) { |
| case "created_asc": { |
| const da = recordDate(a)?.getTime() || 0; |
| const db = recordDate(b)?.getTime() || 0; |
| return da - db; |
| } |
| case "total_desc": |
| return (Number(b.total) || 0) - (Number(a.total) || 0); |
| case "total_asc": |
| return (Number(a.total) || 0) - (Number(b.total) || 0); |
| case "name_asc": |
| return cmpStr(a.customer_name, b.customer_name); |
| case "name_desc": |
| return cmpStr(b.customer_name, a.customer_name); |
| case "status": |
| return (STATUS_RANK[a.status] ?? 99) - (STATUS_RANK[b.status] ?? 99); |
| case "payment": |
| return (PAY_RANK[payState(a)] ?? 99) - (PAY_RANK[payState(b)] ?? 99); |
| case "service": |
| return ( |
| SERVICE_ORDER.indexOf(a.service) - SERVICE_ORDER.indexOf(b.service) || |
| cmpStr(a.ref, b.ref) |
| ); |
| case "ref": |
| return cmpStr(a.ref, b.ref); |
| case "created_desc": |
| default: { |
| const da = recordDate(a)?.getTime() || 0; |
| const db = recordDate(b)?.getTime() || 0; |
| return db - da; |
| } |
| } |
| }); |
| return rows; |
| } |
|
|
| function initLogin() { |
| const form = document.querySelector("[data-admin-login]"); |
| if (!form) return; |
| if (token()) { |
| window.location.href = "home.html"; |
| return; |
| } |
| const meta = moduleMeta(); |
| document.querySelectorAll("[data-module-name]").forEach((el) => { |
| el.textContent = meta.name; |
| }); |
| document.querySelectorAll("[data-module-id]").forEach((el) => { |
| el.textContent = meta.id; |
| }); |
| const errEl = document.querySelector("[data-login-error]"); |
| form.addEventListener("submit", async (event) => { |
| event.preventDefault(); |
| if (errEl) errEl.hidden = true; |
| const data = new FormData(form); |
| try { |
| const res = await api(`${adminApiBase()}/login`, { |
| method: "POST", |
| body: JSON.stringify({ |
| username: String(data.get("username") || ""), |
| password: String(data.get("password") || ""), |
| }), |
| }); |
| setToken(res.token); |
| window.location.href = "home.html"; |
| } catch (e) { |
| if (errEl) { |
| errEl.hidden = false; |
| errEl.textContent = e.message === "Unauthorized" ? "Invalid username or password." : e.message; |
| } |
| } |
| }); |
| } |
|
|
| function bindLogout() { |
| document.querySelector("[data-admin-logout]")?.addEventListener("click", async () => { |
| try { |
| await api(`${adminApiBase()}/logout`, { method: "POST" }); |
| } catch (e) { |
| |
| } |
| setToken(""); |
| window.location.href = loginPath(); |
| }); |
| } |
|
|
| function initHome() { |
| if (!token()) { |
| window.location.href = loginPath(); |
| return; |
| } |
| const meta = moduleMeta(); |
| document.querySelectorAll("[data-module-name]").forEach((el) => { |
| el.textContent = meta.name; |
| }); |
| document.querySelectorAll("[data-module-id]").forEach((el) => { |
| el.textContent = meta.id; |
| }); |
| bindLogout(); |
|
|
| const statsEl = document.querySelector("[data-home-stats]"); |
| const liveEl = document.querySelector("[data-admin-live]"); |
|
|
| const load = async () => { |
| try { |
| const bookings = await api(`${adminApiBase()}/bookings?limit=300`); |
| const today = new Date().toISOString().slice(0, 10); |
| let open = 0; |
| let todayCount = 0; |
| let due = 0; |
| let paid = 0; |
| bookings.forEach((b) => { |
| if (!["completed", "cancelled"].includes(b.status)) open += 1; |
| const d = recordDate(b); |
| if (d && d.toISOString().slice(0, 10) === today) todayCount += 1; |
| const st = payState(b); |
| const total = Number(b.total) || 0; |
| if (st === "paid") paid += total; |
| else if (st === "unpaid" || st === "awaiting" || st === "failed") due += total; |
| }); |
| if (statsEl) { |
| statsEl.innerHTML = ` |
| <div class="admin-stat"><b>${bookings.length}</b><span>all</span></div> |
| <div class="admin-stat"><b>${open}</b><span>open</span></div> |
| <div class="admin-stat"><b>${todayCount}</b><span>today</span></div> |
| <div class="admin-stat"><b>${money(paid)}</b><span>taken</span></div> |
| <div class="admin-stat"><b>${money(due)}</b><span>due</span></div> |
| `; |
| } |
| if (liveEl) liveEl.classList.remove("is-stale"); |
| } catch (e) { |
| if (statsEl) statsEl.innerHTML = `<div class="admin-stat"><b>—</b><span>${esc(e.message)}</span></div>`; |
| if (liveEl) liveEl.classList.add("is-stale"); |
| } |
| }; |
|
|
| load(); |
| setInterval(load, 60000); |
| } |
|
|
| function initDashboard() { |
| if (!token()) { |
| window.location.href = loginPath(); |
| return; |
| } |
|
|
| const meta = moduleMeta(); |
| document.querySelectorAll("[data-module-name]").forEach((el) => { |
| el.textContent = meta.name; |
| }); |
| document.querySelectorAll("[data-module-id]").forEach((el) => { |
| el.textContent = meta.id; |
| }); |
|
|
| const groupsEl = document.querySelector("[data-admin-groups]"); |
| const statsEl = document.querySelector("[data-admin-stats]"); |
| const modal = document.querySelector("[data-admin-modal]"); |
| const modalBody = document.querySelector("[data-modal-body]"); |
| const modalTitle = document.querySelector("[data-modal-title]"); |
| const modalStatus = document.querySelector("[data-modal-status]"); |
| const modalPay = document.querySelector("[data-modal-pay]"); |
| const liveEl = document.querySelector("[data-admin-live]"); |
|
|
| const filters = { |
| service: "", |
| status: "", |
| pay: "", |
| search: "", |
| period: "all", |
| from: "", |
| to: "", |
| sort: "created_desc", |
| }; |
|
|
| let bookings = []; |
| let activeId = null; |
|
|
| const syncRangeVisibility = () => { |
| const rangeEl = document.querySelector("[data-custom-range]"); |
| if (rangeEl) rangeEl.hidden = filters.period !== "custom"; |
| }; |
|
|
| const filtered = () => { |
| const q = filters.search.trim().toLowerCase(); |
| const bounds = periodBounds(filters.period, filters.from, filters.to); |
| const list = bookings.filter((b) => { |
| if (filters.service && b.service !== filters.service) return false; |
| if (filters.status && b.status !== filters.status) return false; |
| if (filters.pay && payState(b) !== filters.pay) return false; |
| if (bounds) { |
| const d = recordDate(b); |
| if (!d) return false; |
| if (bounds.from && d < bounds.from) return false; |
| if (bounds.to && d > bounds.to) return false; |
| } |
| if (!q) return true; |
| const hay = [ |
| b.ref, |
| b.customer_name, |
| b.phone, |
| b.email, |
| b.notes, |
| b.address, |
| itemsLine(b), |
| b.payment, |
| ] |
| .filter(Boolean) |
| .join(" ") |
| .toLowerCase(); |
| return hay.includes(q); |
| }); |
| return sortBookings(list, filters.sort); |
| }; |
|
|
| const groupStats = (list) => { |
| let paid = 0; |
| let unpaid = 0; |
| let failed = 0; |
| let awaiting = 0; |
| let revenue = 0; |
| let due = 0; |
| list.forEach((b) => { |
| const st = payState(b); |
| const total = Number(b.total) || 0; |
| if (st === "paid") { |
| paid += 1; |
| revenue += total; |
| } else if (st === "failed") { |
| failed += 1; |
| due += total; |
| } else if (st === "awaiting") { |
| awaiting += 1; |
| due += total; |
| } else if (st === "unpaid") { |
| unpaid += 1; |
| due += total; |
| } |
| }); |
| return { count: list.length, paid, unpaid, failed, awaiting, revenue, due }; |
| }; |
|
|
| const renderStats = () => { |
| if (!statsEl) return; |
| const list = filtered(); |
| const s = groupStats(list); |
| const open = list.filter((b) => !["completed", "cancelled"].includes(b.status)).length; |
| statsEl.innerHTML = ` |
| <div class="admin-stat"><b>${s.count}</b><span>all</span></div> |
| <div class="admin-stat"><b>${open}</b><span>open</span></div> |
| <div class="admin-stat"><b>${money(s.revenue)}</b><span>taken</span></div> |
| <div class="admin-stat"><b>${money(s.due)}</b><span>due</span></div> |
| <div class="admin-stat admin-stat--mix"> |
| <em data-tone="paid">${s.paid}✓</em> |
| <em data-tone="awaiting">${s.awaiting}…</em> |
| <em data-tone="unpaid">${s.unpaid}○</em> |
| <em data-tone="failed">${s.failed}!</em> |
| </div> |
| `; |
| }; |
|
|
| const renderCard = (b) => { |
| const pay = payMeta(payState(b), b.service); |
| const amount = b.kind === "reservation" |
| ? (b.party_size ? `${b.party_size}p` : "—") |
| : money(b.total); |
| const when = fmtWhen(b); |
| const where = locationLine(b); |
| const bits = [where, when].filter(Boolean).join(" · "); |
| return ` |
| <article class="admin-card" data-open-id="${esc(b.id)}"> |
| <div class="admin-card__id"> |
| <span class="admin-card__kind">${esc(idLabel(b))}</span> |
| <strong>${esc(b.ref)}</strong> |
| </div> |
| <div class="admin-card__main"> |
| <div class="admin-card__line"> |
| <span class="admin-card__guest">${esc(b.customer_name)}</span> |
| <span class="admin-card__items">${esc(itemsLine(b))}</span> |
| </div> |
| <div class="admin-card__meta">${esc(bits)}</div> |
| </div> |
| <div class="admin-card__side"> |
| <span class="admin-card__amt">${esc(amount)}</span> |
| <span class="admin-pill admin-pill--${esc(b.status)}">${esc(b.status)}</span> |
| <span class="admin-pay admin-pay--${pay.tone}">${esc(pay.label)}</span> |
| </div> |
| </article> |
| `; |
| }; |
|
|
| const renderGroups = () => { |
| if (!groupsEl) return; |
| const list = filtered(); |
| if (!list.length) { |
| groupsEl.innerHTML = `<p class="admin-empty">No matches.</p>`; |
| return; |
| } |
|
|
| const byService = {}; |
| SERVICE_ORDER.forEach((key) => { |
| byService[key] = []; |
| }); |
| list.forEach((b) => { |
| const key = SERVICE_ORDER.includes(b.service) ? b.service : "collection"; |
| byService[key].push(b); |
| }); |
|
|
| const keys = filters.service ? [filters.service] : SERVICE_ORDER.filter((k) => byService[k].length); |
| groupsEl.innerHTML = keys |
| .map((key) => { |
| const rows = byService[key] || []; |
| if (!rows.length) return ""; |
| const s = groupStats(rows); |
| const sub = |
| key === "reservation" |
| ? `${s.count}` |
| : `${s.count} · ${money(s.revenue)} in · ${money(s.due)} due`; |
| return ` |
| <section class="admin-group" data-group="${esc(key)}"> |
| <header class="admin-group__head"> |
| <h3>${esc(serviceLabel(key))} <span>${esc(sub)}</span></h3> |
| <div class="admin-group__chips"> |
| ${s.paid ? `<span class="admin-chip admin-chip--paid">${s.paid}✓</span>` : ""} |
| ${s.awaiting ? `<span class="admin-chip admin-chip--awaiting">${s.awaiting}…</span>` : ""} |
| ${s.unpaid ? `<span class="admin-chip admin-chip--unpaid">${s.unpaid}○</span>` : ""} |
| ${s.failed ? `<span class="admin-chip admin-chip--failed">${s.failed}!</span>` : ""} |
| </div> |
| </header> |
| <div class="admin-group__list">${rows.map(renderCard).join("")}</div> |
| </section> |
| `; |
| }) |
| .join(""); |
|
|
| groupsEl.querySelectorAll("[data-open-id]").forEach((el) => { |
| el.addEventListener("click", () => openModal(el.dataset.openId)); |
| }); |
| }; |
|
|
| const renderDetail = (b) => { |
| const items = Array.isArray(b.items) ? b.items : []; |
| const tables = Array.isArray(b.tables) ? b.tables : []; |
| const pay = payMeta(payState(b), b.service); |
| return ` |
| <div class="admin-detail"> |
| <div class="admin-detail__banner"> |
| <span class="admin-pill admin-pill--${esc(b.status)}">${esc(b.status)}</span> |
| <span class="admin-pay admin-pay--${pay.tone}">${esc(pay.label)}</span> |
| ${b.kind !== "reservation" ? `<strong>${esc(money(b.total))}</strong>` : ""} |
| </div> |
| <dl> |
| <dt>${esc(idLabel(b))}</dt><dd><strong>${esc(b.ref)}</strong></dd> |
| <dt>Guest</dt><dd>${esc(b.customer_name)}${contactLine(b) ? ` · ${esc(contactLine(b))}` : ""}</dd> |
| ${ |
| b.service === "sitting-in" || b.service === "reservation" |
| ? `<dt>Table</dt><dd>${esc(tableLine(b) || "—")}</dd>` |
| : "" |
| } |
| ${ |
| b.service === "delivery" |
| ? `<dt>To</dt><dd>${esc(b.address || "—")}</dd>` |
| : "" |
| } |
| ${ |
| b.service === "reservation" |
| ? `<dt>When</dt><dd>${esc(locationLine(b))}</dd>` |
| : `<dt>At</dt><dd>${esc(fmtWhen(b) || "—")}</dd>` |
| } |
| <dt>Pay</dt><dd>${esc(b.payment || "—")}</dd> |
| ${b.notes ? `<dt>Note</dt><dd>${esc(b.notes)}</dd>` : ""} |
| </dl> |
| ${ |
| tables.length && b.service === "reservation" |
| ? `<ul class="admin-detail__items">${tables |
| .map((t) => `<li><span>T${esc(t.label || t.id)}</span><span>${esc(t.seats || "?")} seats</span></li>`) |
| .join("")}</ul>` |
| : "" |
| } |
| ${ |
| items.length |
| ? `<ul class="admin-detail__items">${items |
| .map( |
| (i) => |
| `<li><span>${esc(i.qty || 1)}× ${esc(i.name)}</span><span>${esc( |
| money((i.qty || 1) * (i.price || 0)) |
| )}</span></li>` |
| ) |
| .join("")}</ul>` |
| : "" |
| } |
| </div> |
| `; |
| }; |
|
|
| const openModal = (id) => { |
| const b = bookings.find((row) => row.id === id); |
| if (!b || !modal) return; |
| activeId = id; |
| if (modalTitle) modalTitle.textContent = `${b.ref} · ${b.customer_name}`; |
| if (modalBody) modalBody.innerHTML = renderDetail(b); |
| if (modalStatus) modalStatus.value = b.status; |
| if (modalPay) modalPay.value = payState(b); |
| modal.showModal(); |
| }; |
|
|
| const render = () => { |
| renderStats(); |
| renderGroups(); |
| }; |
|
|
| const load = async () => { |
| try { |
| bookings = await api(`${adminApiBase()}/bookings?limit=300`); |
| render(); |
| if (liveEl) liveEl.classList.remove("is-stale"); |
| } catch (e) { |
| if (groupsEl) groupsEl.innerHTML = `<p class="admin-empty">${esc(e.message)}</p>`; |
| if (liveEl) liveEl.classList.add("is-stale"); |
| } |
| }; |
|
|
| document.querySelector("[data-admin-refresh]")?.addEventListener("click", load); |
| bindLogout(); |
|
|
| document.querySelectorAll("[data-filter-service]").forEach((btn) => { |
| btn.addEventListener("click", () => { |
| filters.service = btn.dataset.filterService || ""; |
| document |
| .querySelectorAll("[data-filter-service]") |
| .forEach((b) => b.classList.toggle("is-active", b === btn)); |
| render(); |
| }); |
| }); |
|
|
| document.querySelectorAll("[data-period]").forEach((btn) => { |
| btn.addEventListener("click", () => { |
| filters.period = btn.dataset.period || "all"; |
| document |
| .querySelectorAll("[data-period]") |
| .forEach((b) => b.classList.toggle("is-active", b === btn)); |
| syncRangeVisibility(); |
| render(); |
| }); |
| }); |
|
|
| document.querySelector("[data-filter-from]")?.addEventListener("change", (e) => { |
| filters.from = e.target.value; |
| if (filters.period !== "custom") { |
| filters.period = "custom"; |
| document.querySelectorAll("[data-period]").forEach((b) => { |
| b.classList.toggle("is-active", b.dataset.period === "custom"); |
| }); |
| syncRangeVisibility(); |
| } |
| render(); |
| }); |
| document.querySelector("[data-filter-to]")?.addEventListener("change", (e) => { |
| filters.to = e.target.value; |
| if (filters.period !== "custom") { |
| filters.period = "custom"; |
| document.querySelectorAll("[data-period]").forEach((b) => { |
| b.classList.toggle("is-active", b.dataset.period === "custom"); |
| }); |
| syncRangeVisibility(); |
| } |
| render(); |
| }); |
|
|
| document.querySelector("[data-sort-by]")?.addEventListener("change", (e) => { |
| filters.sort = e.target.value || "created_desc"; |
| render(); |
| }); |
| document.querySelector("[data-filter-pay]")?.addEventListener("change", (e) => { |
| filters.pay = e.target.value; |
| render(); |
| }); |
| document.querySelector("[data-filter-status]")?.addEventListener("change", (e) => { |
| filters.status = e.target.value; |
| render(); |
| }); |
| document.querySelector("[data-filter-search]")?.addEventListener("input", (e) => { |
| filters.search = e.target.value; |
| render(); |
| }); |
|
|
| syncRangeVisibility(); |
|
|
| document.querySelector("[data-modal-close]")?.addEventListener("click", () => modal?.close()); |
| document.querySelector("[data-modal-save]")?.addEventListener("click", async () => { |
| if (!activeId || !modalStatus) return; |
| try { |
| await api(`${adminApiBase()}/bookings/${activeId}`, { |
| method: "PATCH", |
| body: JSON.stringify({ |
| status: modalStatus.value, |
| payment_status: modalPay ? modalPay.value : undefined, |
| }), |
| }); |
| modal?.close(); |
| await load(); |
| } catch (e) { |
| alert(e.message); |
| } |
| }); |
|
|
| try { |
| const proto = location.protocol === "https:" ? "wss" : "ws"; |
| const host = |
| location.port === "8877" || !location.host |
| ? "127.0.0.1:7860" |
| : location.host; |
| const ws = new WebSocket(`${proto}://${host}/ws`); |
| ws.addEventListener("message", (event) => { |
| try { |
| const msg = JSON.parse(event.data); |
| if (msg.type !== "booking.created" && msg.type !== "booking.updated") return; |
| const mid = msg.booking?.module_id; |
| if (mid && mid !== moduleId()) return; |
| load(); |
| } catch (e) { |
| |
| } |
| }); |
| } catch (e) { |
| |
| } |
|
|
| load(); |
| setInterval(load, 60000); |
| } |
|
|
| function menuDrinkIds() { |
| const menu = window.CornerCafeMenu || []; |
| return new Set(menu.filter((i) => i.section === "Drinks").map((i) => i.id)); |
| } |
|
|
| function isDrinkItem(item, drinkIds) { |
| if (!item) return false; |
| if (String(item.section || "").toLowerCase() === "drinks") return true; |
| if (item.id && drinkIds.has(item.id)) return true; |
| const name = String(item.name || "").toLowerCase(); |
| return /(latte|cappuccino|americano|espresso|flat white|coffee|tea|hot chocolate|juice|irn|coke|fanta|sprite|water|lager|ale|wine|whisky|whiskey|soft drink)/i.test( |
| name |
| ); |
| } |
|
|
| function isIndianFoodItem(item) { |
| return String(item?.section || "").toLowerCase() === "indian"; |
| } |
|
|
| function splitItems(b) { |
| const drinkIds = menuDrinkIds(); |
| const items = Array.isArray(b.items) ? b.items : []; |
| const cafe = []; |
| const indian = []; |
| const drinks = []; |
| items.forEach((i) => { |
| if (isDrinkItem(i, drinkIds)) drinks.push(i); |
| else if (isIndianFoodItem(i)) indian.push(i); |
| else cafe.push(i); |
| }); |
| return { cafe, indian, food: cafe.concat(indian), drinks }; |
| } |
|
|
| function kitchenStatusKey(station) { |
| return station === "indian" ? "indian_kitchen_status" : "kitchen_status"; |
| } |
|
|
| function foodServedKey(station) { |
| return station === "indian" ? "indian_food_served" : "food_served"; |
| } |
|
|
| function foodServedFlag(b, station) { |
| if (station === "indian") return Boolean(b.indian_food_served); |
| return Boolean(b.food_served); |
| } |
|
|
| function itemsLineFrom(items) { |
| if (!items.length) return "—"; |
| return items.map((i) => `${i.qty || 1}× ${i.name || "Item"}`).join(", "); |
| } |
|
|
| function stationTicketId(b) { |
| if (b.service === "sitting-in") { |
| const table = tableLine(b); |
| return table ? `T${table}` : "T—"; |
| } |
| return b.ref || "—"; |
| } |
|
|
| function stationTime(b) { |
| const d = recordDate(b); |
| if (!d) return "—"; |
| return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); |
| } |
|
|
| function stationKind(b) { |
| return b.service === "sitting-in" ? "SIT" : b.service === "delivery" ? "DEL" : "COL"; |
| } |
|
|
| function isCookQueueOrder(b) { |
| if (b.kind !== "order") return false; |
| if (["completed", "cancelled"].includes(b.status)) return false; |
| if (b.service === "sitting-in") return true; |
| if (b.service === "delivery" || b.service === "collection") return payState(b) === "paid"; |
| return false; |
| } |
|
|
| function stationState(b, key) { |
| const raw = String(b[key] || "not_started").toLowerCase(); |
| if (["not_started", "in_progress", "done"].includes(raw)) return raw; |
| return "not_started"; |
| } |
|
|
| function bindStationLive(load) { |
| try { |
| const proto = location.protocol === "https:" ? "wss" : "ws"; |
| const host = |
| location.port === "8877" || !location.host ? "127.0.0.1:7860" : location.host; |
| const ws = new WebSocket(`${proto}://${host}/ws`); |
| ws.addEventListener("message", (event) => { |
| try { |
| const msg = JSON.parse(event.data); |
| if (msg.type !== "booking.created" && msg.type !== "booking.updated") return; |
| const mid = msg.booking?.module_id; |
| if (mid && mid !== moduleId()) return; |
| load(); |
| } catch (e) { |
| |
| } |
| }); |
| } catch (e) { |
| |
| } |
| } |
|
|
| function initStationShell() { |
| if (!token()) { |
| window.location.href = loginPath(); |
| return false; |
| } |
| const meta = moduleMeta(); |
| document.querySelectorAll("[data-module-name]").forEach((el) => { |
| el.textContent = meta.name; |
| }); |
| document.querySelectorAll("[data-module-id]").forEach((el) => { |
| el.textContent = meta.id; |
| }); |
| bindLogout(); |
| return true; |
| } |
|
|
| function renderProgressButtons(active, attr) { |
| return ["not_started", "in_progress", "done"] |
| .map((k) => { |
| const label = |
| k === "not_started" ? "Not started" : k === "in_progress" ? "In progress" : "Done"; |
| return `<button type="button" class="chef-state${ |
| active === k ? " is-active" : "" |
| }" data-${attr}="${k}">${label}</button>`; |
| }) |
| .join(""); |
| } |
|
|
| function initChef() { |
| if (!initStationShell()) return; |
| const boardEl = document.querySelector("[data-chef-board]"); |
| const liveEl = document.querySelector("[data-admin-live]"); |
| let filter = "active"; |
| let kitchen = "cafe"; |
| let bookings = []; |
|
|
| const stationItems = (b) => { |
| const split = splitItems(b); |
| return kitchen === "indian" ? split.indian : split.cafe; |
| }; |
|
|
| const statusKey = () => kitchenStatusKey(kitchen); |
|
|
| const render = () => { |
| if (!boardEl) return; |
| let list = bookings.filter((b) => isCookQueueOrder(b) && stationItems(b).length); |
| const key = statusKey(); |
| if (filter === "online") { |
| list = list.filter( |
| (b) => |
| (b.service === "delivery" || b.service === "collection") && |
| stationState(b, key) !== "done" |
| ); |
| } else if (filter === "sitting-in") { |
| list = list.filter( |
| (b) => b.service === "sitting-in" && stationState(b, key) !== "done" |
| ); |
| } else if (filter === "done") { |
| list = list.filter((b) => stationState(b, key) === "done"); |
| } else { |
| list = list.filter((b) => stationState(b, key) !== "done"); |
| } |
| list.sort((a, b) => (recordDate(a)?.getTime() || 0) - (recordDate(b)?.getTime() || 0)); |
| if (!list.length) { |
| boardEl.innerHTML = `<p class="admin-empty">No ${ |
| kitchen === "indian" ? "Indian cuisine" : "cafe" |
| } tickets.</p>`; |
| return; |
| } |
| boardEl.innerHTML = list |
| .map((b) => { |
| const kind = stationKind(b); |
| const k = stationState(b, key); |
| const food = stationItems(b); |
| return ` |
| <article class="chef-ticket chef-ticket--${esc(kind.toLowerCase())} chef-ticket--${esc(k)}" data-station-id="${esc(b.id)}"> |
| <header class="chef-ticket__head"> |
| <time>${esc(stationTime(b))}</time> |
| <span class="chef-ticket__kind">${esc(kind)}</span> |
| <strong class="chef-ticket__id">${esc(stationTicketId(b))}</strong> |
| </header> |
| <p class="chef-ticket__food">${esc(itemsLineFrom(food))}</p> |
| <div class="chef-ticket__actions" role="group" aria-label="Kitchen status"> |
| ${renderProgressButtons(k, "kitchen")} |
| </div> |
| </article>`; |
| }) |
| .join(""); |
| boardEl.querySelectorAll("[data-station-id]").forEach((card) => { |
| card.querySelectorAll("[data-kitchen]").forEach((btn) => { |
| btn.addEventListener("click", async (event) => { |
| event.stopPropagation(); |
| try { |
| await api(`${adminApiBase()}/bookings/${card.dataset.stationId}`, { |
| method: "PATCH", |
| body: JSON.stringify({ [statusKey()]: btn.dataset.kitchen }), |
| }); |
| await load(); |
| } catch (e) { |
| alert(e.message); |
| } |
| }); |
| }); |
| }); |
| }; |
|
|
| const load = async () => { |
| try { |
| bookings = await api(`${adminApiBase()}/bookings?limit=300`); |
| render(); |
| if (liveEl) liveEl.classList.remove("is-stale"); |
| } catch (e) { |
| if (boardEl) boardEl.innerHTML = `<p class="admin-empty">${esc(e.message)}</p>`; |
| if (liveEl) liveEl.classList.add("is-stale"); |
| } |
| }; |
|
|
| document.querySelector("[data-chef-refresh]")?.addEventListener("click", load); |
| document.querySelectorAll("[data-chef-filter]").forEach((btn) => { |
| btn.addEventListener("click", () => { |
| filter = btn.dataset.chefFilter || "active"; |
| document |
| .querySelectorAll("[data-chef-filter]") |
| .forEach((b) => b.classList.toggle("is-active", b === btn)); |
| render(); |
| }); |
| }); |
| document.querySelectorAll("[data-chef-kitchen]").forEach((btn) => { |
| btn.addEventListener("click", () => { |
| kitchen = btn.dataset.chefKitchen || "cafe"; |
| document |
| .querySelectorAll("[data-chef-kitchen]") |
| .forEach((b) => { |
| const on = b === btn; |
| b.classList.toggle("is-active", on); |
| b.setAttribute("aria-selected", on ? "true" : "false"); |
| }); |
| render(); |
| }); |
| }); |
| bindStationLive(load); |
| load(); |
| setInterval(load, 15000); |
| } |
|
|
| function initBarista() { |
| if (!initStationShell()) return; |
| const boardEl = document.querySelector("[data-barista-board]"); |
| const liveEl = document.querySelector("[data-admin-live]"); |
| let filter = "active"; |
| let bookings = []; |
|
|
| const render = () => { |
| if (!boardEl) return; |
| let list = bookings.filter((b) => isCookQueueOrder(b) && splitItems(b).drinks.length); |
| if (filter === "online") { |
| list = list.filter( |
| (b) => |
| (b.service === "delivery" || b.service === "collection") && |
| stationState(b, "barista_status") !== "done" |
| ); |
| } else if (filter === "sitting-in") { |
| list = list.filter( |
| (b) => b.service === "sitting-in" && stationState(b, "barista_status") !== "done" |
| ); |
| } else if (filter === "done") { |
| list = list.filter((b) => stationState(b, "barista_status") === "done"); |
| } else { |
| list = list.filter((b) => stationState(b, "barista_status") !== "done"); |
| } |
| list.sort((a, b) => (recordDate(a)?.getTime() || 0) - (recordDate(b)?.getTime() || 0)); |
| if (!list.length) { |
| boardEl.innerHTML = `<p class="admin-empty">No drinks tickets.</p>`; |
| return; |
| } |
| boardEl.innerHTML = list |
| .map((b) => { |
| const kind = stationKind(b); |
| const k = stationState(b, "barista_status"); |
| const drinks = splitItems(b).drinks; |
| return ` |
| <article class="chef-ticket chef-ticket--${esc(kind.toLowerCase())} chef-ticket--${esc(k)}" data-station-id="${esc(b.id)}"> |
| <header class="chef-ticket__head"> |
| <time>${esc(stationTime(b))}</time> |
| <span class="chef-ticket__kind">${esc(kind)}</span> |
| <strong class="chef-ticket__id">${esc(stationTicketId(b))}</strong> |
| </header> |
| <p class="chef-ticket__food">${esc(itemsLineFrom(drinks))}</p> |
| <div class="chef-ticket__actions" role="group" aria-label="Barista status"> |
| ${renderProgressButtons(k, "barista")} |
| </div> |
| </article>`; |
| }) |
| .join(""); |
| boardEl.querySelectorAll("[data-station-id]").forEach((card) => { |
| card.querySelectorAll("[data-barista]").forEach((btn) => { |
| btn.addEventListener("click", async (event) => { |
| event.stopPropagation(); |
| try { |
| await api(`${adminApiBase()}/bookings/${card.dataset.stationId}`, { |
| method: "PATCH", |
| body: JSON.stringify({ barista_status: btn.dataset.barista }), |
| }); |
| await load(); |
| } catch (e) { |
| alert(e.message); |
| } |
| }); |
| }); |
| }); |
| }; |
|
|
| const load = async () => { |
| try { |
| bookings = await api(`${adminApiBase()}/bookings?limit=300`); |
| render(); |
| if (liveEl) liveEl.classList.remove("is-stale"); |
| } catch (e) { |
| if (boardEl) boardEl.innerHTML = `<p class="admin-empty">${esc(e.message)}</p>`; |
| if (liveEl) liveEl.classList.add("is-stale"); |
| } |
| }; |
|
|
| document.querySelector("[data-barista-refresh]")?.addEventListener("click", load); |
| document.querySelectorAll("[data-barista-filter]").forEach((btn) => { |
| btn.addEventListener("click", () => { |
| filter = btn.dataset.baristaFilter || "active"; |
| document |
| .querySelectorAll("[data-barista-filter]") |
| .forEach((b) => b.classList.toggle("is-active", b === btn)); |
| render(); |
| }); |
| }); |
| bindStationLive(load); |
| load(); |
| setInterval(load, 15000); |
| } |
|
|
| function initWaiter() { |
| if (!initStationShell()) return; |
| const boardEl = document.querySelector("[data-waiter-board]"); |
| const liveEl = document.querySelector("[data-admin-live]"); |
| const hintEl = document.querySelector("[data-waiter-hint]"); |
| let filter = "ready"; |
| let bookings = []; |
|
|
| const readyRows = () => { |
| const rows = []; |
| bookings.forEach((b) => { |
| if (b.kind !== "order" || b.status === "cancelled") return; |
| const { cafe, indian, drinks } = splitItems(b); |
| const cafeDone = stationState(b, "kitchen_status") === "done"; |
| const indianDone = stationState(b, "indian_kitchen_status") === "done"; |
| const baristaDone = stationState(b, "barista_status") === "done"; |
| if (filter === "served") { |
| if (cafe.length && foodServedFlag(b, "cafe")) { |
| rows.push({ booking: b, type: "food", station: "cafe", items: cafe, served: true }); |
| } |
| if (indian.length && foodServedFlag(b, "indian")) { |
| rows.push({ |
| booking: b, |
| type: "food", |
| station: "indian", |
| items: indian, |
| served: true, |
| }); |
| } |
| if (drinks.length && b.drinks_served) { |
| rows.push({ booking: b, type: "drinks", station: null, items: drinks, served: true }); |
| } |
| return; |
| } |
| if (cafe.length && cafeDone && !foodServedFlag(b, "cafe")) { |
| rows.push({ booking: b, type: "food", station: "cafe", items: cafe }); |
| } |
| if (indian.length && indianDone && !foodServedFlag(b, "indian")) { |
| rows.push({ booking: b, type: "food", station: "indian", items: indian }); |
| } |
| if (drinks.length && baristaDone && !b.drinks_served) { |
| rows.push({ booking: b, type: "drinks", station: null, items: drinks }); |
| } |
| }); |
| return rows; |
| }; |
|
|
| const sitInTables = () => |
| bookings |
| .filter( |
| (b) => |
| b.kind === "order" && |
| b.service === "sitting-in" && |
| !["completed", "cancelled"].includes(b.status) |
| ) |
| .sort((a, b) => (recordDate(a)?.getTime() || 0) - (recordDate(b)?.getTime() || 0)); |
|
|
| const bindServeActions = () => { |
| boardEl.querySelectorAll("[data-serve]").forEach((btn) => { |
| btn.addEventListener("click", async (event) => { |
| event.stopPropagation(); |
| const card = btn.closest("[data-station-id]"); |
| const id = card.dataset.stationId; |
| const type = card.dataset.serveType; |
| const station = card.dataset.serveStation || "cafe"; |
| const b = bookings.find((row) => row.id === id); |
| if (!b) return; |
| const { cafe, indian, drinks } = splitItems(b); |
| const body = {}; |
| if (type === "food") { |
| body[foodServedKey(station)] = true; |
| if (station === "cafe" && !indian.length) body.indian_food_served = true; |
| if (station === "indian" && !cafe.length) body.food_served = true; |
| if (!drinks.length) body.drinks_served = true; |
| } else { |
| body.drinks_served = true; |
| if (!cafe.length) body.food_served = true; |
| if (!indian.length) body.indian_food_served = true; |
| } |
| try { |
| await api(`${adminApiBase()}/bookings/${id}`, { |
| method: "PATCH", |
| body: JSON.stringify(body), |
| }); |
| await load(); |
| } catch (e) { |
| alert(e.message); |
| } |
| }); |
| }); |
| }; |
|
|
| const bindTableActions = () => { |
| boardEl.querySelectorAll("[data-print-receipt]").forEach((btn) => { |
| btn.addEventListener("click", (event) => { |
| event.stopPropagation(); |
| const id = btn.closest("[data-station-id]")?.dataset.stationId; |
| const b = bookings.find((row) => row.id === id); |
| if (!b) return; |
| if (window.SmOSReceipt?.openPrint) { |
| window.SmOSReceipt.openPrint(b, { businessName: "The Corner Cafe" }); |
| } else { |
| alert("Receipt helper not loaded."); |
| } |
| }); |
| }); |
| boardEl.querySelectorAll("[data-mark-paid]").forEach((btn) => { |
| btn.addEventListener("click", async (event) => { |
| event.stopPropagation(); |
| const id = btn.closest("[data-station-id]")?.dataset.stationId; |
| if (!id) return; |
| try { |
| await api(`${adminApiBase()}/bookings/${id}`, { |
| method: "PATCH", |
| body: JSON.stringify({ payment_status: "paid" }), |
| }); |
| await load(); |
| } catch (e) { |
| alert(e.message); |
| } |
| }); |
| }); |
| boardEl.querySelectorAll("[data-release-table]").forEach((btn) => { |
| btn.addEventListener("click", async (event) => { |
| event.stopPropagation(); |
| const card = btn.closest("[data-station-id]"); |
| const id = card?.dataset.stationId; |
| if (!id) return; |
| const paid = card.dataset.paid === "1"; |
| if (!paid) { |
| alert("Mark the table paid before releasing it."); |
| return; |
| } |
| if ( |
| !confirm( |
| "Release this table? Guest has left and the table is cleaned — it becomes vacant." |
| ) |
| ) { |
| return; |
| } |
| try { |
| await api(`${adminApiBase()}/bookings/${id}`, { |
| method: "PATCH", |
| body: JSON.stringify({ |
| payment_status: "paid", |
| status: "completed", |
| food_served: true, |
| indian_food_served: true, |
| drinks_served: true, |
| }), |
| }); |
| await load(); |
| } catch (e) { |
| alert(e.message); |
| } |
| }); |
| }); |
| }; |
|
|
| const renderServeBoard = () => { |
| let rows = readyRows(); |
| if (filter === "food") rows = rows.filter((r) => r.type === "food" && !r.served); |
| else if (filter === "drinks") rows = rows.filter((r) => r.type === "drinks" && !r.served); |
| else if (filter === "served") rows = rows.filter((r) => r.served); |
| else rows = rows.filter((r) => !r.served); |
|
|
| rows.sort( |
| (a, b) => |
| (recordDate(a.booking)?.getTime() || 0) - (recordDate(b.booking)?.getTime() || 0) |
| ); |
|
|
| if (!rows.length) { |
| boardEl.innerHTML = `<p class="admin-empty">Nothing to serve.</p>`; |
| return; |
| } |
|
|
| boardEl.innerHTML = rows |
| .map((row) => { |
| const b = row.booking; |
| const kind = stationKind(b); |
| const label = |
| row.type === "drinks" |
| ? "DRINKS" |
| : row.station === "indian" |
| ? "INDIAN" |
| : "CAFE"; |
| return ` |
| <article class="chef-ticket chef-ticket--${esc(kind.toLowerCase())}${ |
| row.served ? " chef-ticket--done" : "" |
| }" data-station-id="${esc(b.id)}" data-serve-type="${esc(row.type)}" data-serve-station="${esc( |
| row.station || "" |
| )}"> |
| <header class="chef-ticket__head"> |
| <time>${esc(stationTime(b))}</time> |
| <span class="chef-ticket__kind">${esc(kind)} · ${esc(label)}</span> |
| <strong class="chef-ticket__id">${esc(stationTicketId(b))}</strong> |
| </header> |
| <p class="chef-ticket__food">${esc(itemsLineFrom(row.items))}</p> |
| ${ |
| row.served |
| ? `<p class="chef-hint">Served</p>` |
| : `<div class="chef-ticket__actions"> |
| <button type="button" class="chef-state is-active" data-serve>Mark served</button> |
| </div>` |
| } |
| </article>`; |
| }) |
| .join(""); |
| bindServeActions(); |
| }; |
|
|
| const renderTablesBoard = () => { |
| const list = sitInTables(); |
| if (!list.length) { |
| boardEl.innerHTML = `<p class="admin-empty">No occupied sit-in tables.</p>`; |
| return; |
| } |
| boardEl.innerHTML = list |
| .map((b) => { |
| const st = payState(b); |
| const pay = payMeta(st, b.service); |
| const paid = st === "paid"; |
| return ` |
| <article class="chef-ticket chef-ticket--sit waiter-table${ |
| paid ? " waiter-table--paid" : "" |
| }" data-station-id="${esc(b.id)}" data-paid="${paid ? "1" : "0"}"> |
| <header class="chef-ticket__head"> |
| <time>${esc(stationTime(b))}</time> |
| <span class="chef-ticket__kind">SIT</span> |
| <strong class="chef-ticket__id">${esc(stationTicketId(b))}</strong> |
| </header> |
| <p class="chef-ticket__food">${esc(itemsLine(b, 4))}</p> |
| <div class="waiter-table__meta"> |
| <strong>${esc(money(b.total))}</strong> |
| <span class="admin-pay admin-pay--${esc(pay.tone)}">${esc(pay.label)}</span> |
| <span class="waiter-table__guest">${esc(b.customer_name || "Guest")}</span> |
| </div> |
| <div class="chef-ticket__actions"> |
| ${ |
| paid |
| ? `<button type="button" class="chef-state is-active" disabled>Paid</button>` |
| : `<button type="button" class="chef-state is-active" data-mark-paid>Mark paid</button>` |
| } |
| <button type="button" class="chef-state${paid ? " is-active" : ""}" data-print-receipt> |
| ${paid ? "Print receipt" : "Preview receipt"} |
| </button> |
| <button type="button" class="chef-state${paid ? " is-active" : ""}" data-release-table ${ |
| paid ? "" : "disabled" |
| }>Release table</button> |
| </div> |
| ${ |
| paid |
| ? `<p class="chef-hint">Print till receipt · release when guest leaves and table is cleaned</p>` |
| : `<p class="chef-hint">Collect payment before releasing · receipt available as PDF/image</p>` |
| } |
| </article>`; |
| }) |
| .join(""); |
| bindTableActions(); |
| }; |
|
|
| const render = () => { |
| if (!boardEl) return; |
| if (hintEl) { |
| hintEl.textContent = |
| filter === "tables" |
| ? "Sit-in tables · mark paid · release when guest leaves and table is cleaned" |
| : "Completed food / drinks ready to take · table / order ID"; |
| } |
| if (filter === "tables") renderTablesBoard(); |
| else renderServeBoard(); |
| }; |
|
|
| const load = async () => { |
| try { |
| bookings = await api(`${adminApiBase()}/bookings?limit=300`); |
| render(); |
| if (liveEl) liveEl.classList.remove("is-stale"); |
| } catch (e) { |
| if (boardEl) boardEl.innerHTML = `<p class="admin-empty">${esc(e.message)}</p>`; |
| if (liveEl) liveEl.classList.add("is-stale"); |
| } |
| }; |
|
|
| document.querySelector("[data-waiter-refresh]")?.addEventListener("click", load); |
| document.querySelectorAll("[data-waiter-filter]").forEach((btn) => { |
| btn.addEventListener("click", () => { |
| filter = btn.dataset.waiterFilter || "ready"; |
| document |
| .querySelectorAll("[data-waiter-filter]") |
| .forEach((b) => b.classList.toggle("is-active", b === btn)); |
| render(); |
| }); |
| }); |
| bindStationLive(load); |
| load(); |
| setInterval(load, 15000); |
| } |
|
|
| document.addEventListener("DOMContentLoaded", initLogin); |
|
|
| return { initDashboard, initHome, initChef, initBarista, initWaiter }; |
| })(); |
|
|