Download modules/SmOS_CC/admin/admin.js from 0001AMA/SMOS: direct link, hf CLI and curl.
- Browser
- Download file 51.2 kB
-
https://huggingface.co/spaces/0001AMA/SMOS/resolve/main/modules/SmOS_CC/admin/admin.js
- Command line
-
hf download hf://spaces/0001AMA/SMOS/modules/SmOS_CC/admin/admin.js
-
curl -L -o admin.js https://huggingface.co/spaces/0001AMA/SMOS/resolve/main/modules/SmOS_CC/admin/admin.js
51.2 kB
| 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: "HAH restaurant", | |
| }; | |
| } | |
| 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) { | |
| /* ignore */ | |
| } | |
| } | |
| 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) { | |
| /* ignore */ | |
| } | |
| 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) { | |
| /* ignore */ | |
| } | |
| }); | |
| } catch (e) { | |
| /* ignore */ | |
| } | |
| load(); | |
| setInterval(load, 60000); | |
| } | |
| function menuDrinkIds() { | |
| const menu = window.HAHRestaurantMenu || []; | |
| 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) { | |
| /* ignore */ | |
| } | |
| }); | |
| } catch (e) { | |
| /* ignore */ | |
| } | |
| } | |
| 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: "HAH restaurant" }); | |
| } 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 }; | |
| })(); | |