SMOS / modules /SmOS_CC /admin /admin.js
0001AMA's picture
Add Mollie card checkout for SmOS_CC delivery and collection, plus staff station screens.
ddd5681 verified
Raw
History Blame Contribute Delete
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: "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) {
/* 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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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.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) {
/* 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: "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 };
})();