2026-07-30 19:19:51 -04:00
|
|
|
|
/* ADMIN_JS_MARKER */
|
|
|
|
|
|
(() => {
|
2026-07-31 11:33:03 -04:00
|
|
|
|
const ADMIN_URL = window.ADMIN_URL;
|
|
|
|
|
|
const ADMIN_PDF_URL = window.ADMIN_PDF_URL;
|
|
|
|
|
|
const MEALS = window.MEALS;
|
|
|
|
|
|
const WEEK = window.WEEK;
|
|
|
|
|
|
const ADMIN_REQUEST_TIMEOUT_MS = 10000;
|
|
|
|
|
|
|
|
|
|
|
|
let currentAdminWeek = "";
|
|
|
|
|
|
let editingOrder = null;
|
|
|
|
|
|
let editQuantities = {};
|
|
|
|
|
|
let lastAdminData = null;
|
|
|
|
|
|
let latestAdminRequest = 0;
|
|
|
|
|
|
|
|
|
|
|
|
function adminHeaders() {
|
|
|
|
|
|
const h = {};
|
|
|
|
|
|
const cred = window.googleCredential;
|
|
|
|
|
|
if (cred) h["Authorization"] = "Bearer " + cred;
|
|
|
|
|
|
return h;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function adminFetch(url, options = {}) {
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
const timeout = setTimeout(
|
|
|
|
|
|
() => controller.abort(),
|
|
|
|
|
|
ADMIN_REQUEST_TIMEOUT_MS,
|
|
|
|
|
|
);
|
|
|
|
|
|
return fetch(url, { ...options, signal: controller.signal }).finally(() =>
|
|
|
|
|
|
clearTimeout(timeout),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setAdminActionDisabled(action, disabled) {
|
|
|
|
|
|
document
|
|
|
|
|
|
.querySelectorAll('[data-admin-action="' + action + '"]')
|
|
|
|
|
|
.forEach((btn) => {
|
|
|
|
|
|
btn.disabled = disabled;
|
|
|
|
|
|
});
|
2026-07-30 19:19:51 -04:00
|
|
|
|
}
|
2026-07-31 11:33:03 -04:00
|
|
|
|
|
|
|
|
|
|
function loadWeekOrders(week) {
|
|
|
|
|
|
if (!week) return;
|
|
|
|
|
|
const requestId = ++latestAdminRequest;
|
|
|
|
|
|
window.FormUtils.clearStatus("admin");
|
|
|
|
|
|
currentAdminWeek = week;
|
|
|
|
|
|
editingOrder = null;
|
|
|
|
|
|
editQuantities = {};
|
|
|
|
|
|
const tbody = document.getElementById("admin-tbody");
|
|
|
|
|
|
tbody.innerHTML =
|
|
|
|
|
|
'<tr><td colspan="5" class="admin-loading">Loading...</td></tr>';
|
|
|
|
|
|
const cards = document.getElementById("admin-cards");
|
|
|
|
|
|
cards.innerHTML = '<div class="admin-loading">Loading...</div>';
|
|
|
|
|
|
document.getElementById("admin-empty").classList.add("is-hidden");
|
|
|
|
|
|
document.getElementById("admin-stats").textContent = "";
|
|
|
|
|
|
|
|
|
|
|
|
adminFetch(ADMIN_URL + "?week=" + encodeURIComponent(week), {
|
|
|
|
|
|
headers: adminHeaders(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.then((r) => {
|
|
|
|
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
|
|
|
|
return r.json();
|
|
|
|
|
|
})
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
if (requestId === latestAdminRequest && week === currentAdminWeek) {
|
|
|
|
|
|
renderAdminTable(data);
|
|
|
|
|
|
}
|
2026-07-31 10:15:43 -04:00
|
|
|
|
})
|
2026-07-31 11:33:03 -04:00
|
|
|
|
.catch(() => {
|
|
|
|
|
|
if (requestId === latestAdminRequest && week === currentAdminWeek) {
|
|
|
|
|
|
tbody.innerHTML =
|
|
|
|
|
|
'<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
|
|
|
|
|
|
cards.innerHTML = "";
|
|
|
|
|
|
window.FormUtils.showStatus("Failed to load orders.", {
|
|
|
|
|
|
tone: "error",
|
|
|
|
|
|
scope: "admin",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.loadWeekOrders = loadWeekOrders;
|
|
|
|
|
|
|
|
|
|
|
|
function buildEditQtyRow(name, qty) {
|
|
|
|
|
|
const wrap = document.createElement("div");
|
|
|
|
|
|
wrap.className = qty === 0 ? "edit-qty removed" : "edit-qty";
|
|
|
|
|
|
|
|
|
|
|
|
const minusBtn = document.createElement("button");
|
|
|
|
|
|
minusBtn.type = "button";
|
|
|
|
|
|
minusBtn.dataset.adminAction = "edit-qty";
|
|
|
|
|
|
minusBtn.dataset.mealName = name;
|
|
|
|
|
|
minusBtn.dataset.delta = "-1";
|
|
|
|
|
|
minusBtn.setAttribute("aria-label", "Remove one " + name);
|
|
|
|
|
|
minusBtn.innerHTML = "−";
|
|
|
|
|
|
|
|
|
|
|
|
const span = document.createElement("span");
|
|
|
|
|
|
span.textContent = qty;
|
|
|
|
|
|
span.setAttribute("aria-label", "Quantity of " + name);
|
|
|
|
|
|
|
|
|
|
|
|
const plusBtn = document.createElement("button");
|
|
|
|
|
|
plusBtn.type = "button";
|
|
|
|
|
|
plusBtn.dataset.adminAction = "edit-qty";
|
|
|
|
|
|
plusBtn.dataset.mealName = name;
|
|
|
|
|
|
plusBtn.dataset.delta = "1";
|
|
|
|
|
|
plusBtn.setAttribute("aria-label", "Add one " + name);
|
|
|
|
|
|
plusBtn.textContent = "+";
|
|
|
|
|
|
|
|
|
|
|
|
wrap.appendChild(minusBtn);
|
|
|
|
|
|
wrap.appendChild(span);
|
|
|
|
|
|
wrap.appendChild(plusBtn);
|
|
|
|
|
|
wrap.appendChild(document.createTextNode(" " + name));
|
|
|
|
|
|
return wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildAddItemSelect(availableMeals) {
|
|
|
|
|
|
const wrap = document.createElement("div");
|
|
|
|
|
|
wrap.className = "admin-add-item";
|
|
|
|
|
|
const select = document.createElement("select");
|
|
|
|
|
|
select.dataset.adminAction = "add-item-select";
|
|
|
|
|
|
|
|
|
|
|
|
const defaultOpt = document.createElement("option");
|
|
|
|
|
|
defaultOpt.value = "";
|
|
|
|
|
|
defaultOpt.textContent = "+ Add item...";
|
|
|
|
|
|
select.appendChild(defaultOpt);
|
|
|
|
|
|
|
|
|
|
|
|
availableMeals.forEach((m) => {
|
|
|
|
|
|
const opt = document.createElement("option");
|
|
|
|
|
|
const price = m.price || 0;
|
|
|
|
|
|
opt.value = m.name;
|
|
|
|
|
|
opt.textContent = m.name + " ($" + price.toFixed(2) + ")";
|
|
|
|
|
|
select.appendChild(opt);
|
2026-07-31 10:15:43 -04:00
|
|
|
|
});
|
2026-07-31 11:33:03 -04:00
|
|
|
|
|
|
|
|
|
|
wrap.appendChild(select);
|
|
|
|
|
|
return wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildActionButton(label, action, extra) {
|
|
|
|
|
|
const btn = document.createElement("button");
|
|
|
|
|
|
btn.type = "button";
|
|
|
|
|
|
btn.dataset.adminAction = action;
|
|
|
|
|
|
if (extra) {
|
|
|
|
|
|
Object.entries(extra).forEach(([key, val]) => {
|
|
|
|
|
|
btn.dataset[key] = val;
|
|
|
|
|
|
});
|
2026-07-31 10:15:43 -04:00
|
|
|
|
}
|
2026-07-31 11:33:03 -04:00
|
|
|
|
btn.textContent = label;
|
|
|
|
|
|
return btn;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildEditActions(email) {
|
|
|
|
|
|
const wrap = document.createElement("div");
|
|
|
|
|
|
wrap.className = "admin-actions";
|
|
|
|
|
|
const saveBtn = buildActionButton("Save", "save-edit", { email });
|
|
|
|
|
|
saveBtn.className = "btn btn-sm btn-save";
|
|
|
|
|
|
const cancelBtn = buildActionButton("Cancel", "cancel-edit");
|
|
|
|
|
|
cancelBtn.className = "btn btn-sm btn-cancel";
|
|
|
|
|
|
wrap.appendChild(saveBtn);
|
|
|
|
|
|
wrap.appendChild(cancelBtn);
|
|
|
|
|
|
return wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildViewActions(email, name) {
|
|
|
|
|
|
const wrap = document.createElement("div");
|
|
|
|
|
|
wrap.className = "admin-actions";
|
|
|
|
|
|
const editBtn = buildActionButton("Edit", "start-edit", { email });
|
|
|
|
|
|
editBtn.className = "btn btn-sm btn-edit";
|
|
|
|
|
|
const deleteBtn = buildActionButton("Delete", "delete", { email, name });
|
|
|
|
|
|
deleteBtn.className = "btn btn-sm btn-delete";
|
|
|
|
|
|
wrap.appendChild(editBtn);
|
|
|
|
|
|
wrap.appendChild(deleteBtn);
|
|
|
|
|
|
return wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatSubmittedAt(submittedAt) {
|
|
|
|
|
|
return submittedAt
|
|
|
|
|
|
? new Date(submittedAt).toLocaleString("en-US", {
|
|
|
|
|
|
timeZone: "America/New_York",
|
|
|
|
|
|
month: "short",
|
|
|
|
|
|
day: "numeric",
|
|
|
|
|
|
hour: "numeric",
|
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
|
})
|
|
|
|
|
|
: "";
|
2026-07-31 10:15:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:33:03 -04:00
|
|
|
|
function editTotal() {
|
|
|
|
|
|
const menuPrices = {};
|
|
|
|
|
|
MEALS.forEach((m) => {
|
|
|
|
|
|
menuPrices[m.name] = m.price;
|
|
|
|
|
|
});
|
|
|
|
|
|
return Object.keys(editQuantities).reduce((sum, name) => {
|
|
|
|
|
|
const price = menuPrices[name] || 0;
|
|
|
|
|
|
return (
|
|
|
|
|
|
sum +
|
|
|
|
|
|
window.FormUtils.employeePrice(price) * (editQuantities[name] || 0)
|
|
|
|
|
|
);
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function populateOrderItems(container, order, isEditing, separatorFactory) {
|
|
|
|
|
|
if (isEditing) {
|
|
|
|
|
|
Object.keys(editQuantities).forEach((name) => {
|
|
|
|
|
|
container.appendChild(buildEditQtyRow(name, editQuantities[name] || 0));
|
|
|
|
|
|
if (separatorFactory) container.appendChild(separatorFactory());
|
|
|
|
|
|
});
|
|
|
|
|
|
const availableMeals = MEALS.filter(
|
|
|
|
|
|
(m) => !Object.prototype.hasOwnProperty.call(editQuantities, m.name),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (availableMeals.length > 0) {
|
|
|
|
|
|
container.appendChild(buildAddItemSelect(availableMeals));
|
|
|
|
|
|
}
|
|
|
|
|
|
return "$" + editTotal().toFixed(2);
|
2026-07-31 10:15:43 -04:00
|
|
|
|
}
|
2026-07-31 11:33:03 -04:00
|
|
|
|
|
|
|
|
|
|
order.items.forEach((item, itemIdx) => {
|
|
|
|
|
|
if (separatorFactory && itemIdx > 0) {
|
|
|
|
|
|
container.appendChild(separatorFactory());
|
|
|
|
|
|
}
|
|
|
|
|
|
const row = separatorFactory ? container : document.createElement("div");
|
|
|
|
|
|
if (!separatorFactory) row.className = "admin-item";
|
|
|
|
|
|
row.appendChild(
|
|
|
|
|
|
document.createTextNode(item.name + " ×" + item.quantity + " "),
|
|
|
|
|
|
);
|
|
|
|
|
|
const sub = document.createElement("span");
|
|
|
|
|
|
sub.className = "admin-muted";
|
|
|
|
|
|
sub.textContent = "($" + item.subtotal.toFixed(2) + ")";
|
|
|
|
|
|
row.appendChild(sub);
|
|
|
|
|
|
if (!separatorFactory) container.appendChild(row);
|
|
|
|
|
|
});
|
|
|
|
|
|
return "$" + order.total.toFixed(2);
|
2026-07-30 19:19:51 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:33:03 -04:00
|
|
|
|
function buildAdminCard(order) {
|
|
|
|
|
|
const card = document.createElement("article");
|
|
|
|
|
|
card.className = "admin-order-card";
|
|
|
|
|
|
card.dataset.email = order.employee_email;
|
2026-07-30 19:19:51 -04:00
|
|
|
|
const isEditing = editingOrder === order.employee_email;
|
|
|
|
|
|
|
2026-07-31 11:33:03 -04:00
|
|
|
|
const header = document.createElement("div");
|
|
|
|
|
|
header.className = "admin-card-header";
|
|
|
|
|
|
const employee = document.createElement("div");
|
|
|
|
|
|
employee.className = "admin-card-employee";
|
|
|
|
|
|
const name = document.createElement("strong");
|
|
|
|
|
|
name.textContent = order.employee_name;
|
|
|
|
|
|
const email = document.createElement("span");
|
|
|
|
|
|
email.className = "admin-muted";
|
|
|
|
|
|
email.textContent = order.employee_email;
|
|
|
|
|
|
employee.appendChild(name);
|
|
|
|
|
|
employee.appendChild(email);
|
|
|
|
|
|
const time = document.createElement("span");
|
|
|
|
|
|
time.className = "admin-card-time";
|
|
|
|
|
|
time.textContent = formatSubmittedAt(order.submitted_at);
|
|
|
|
|
|
header.appendChild(employee);
|
|
|
|
|
|
header.appendChild(time);
|
|
|
|
|
|
|
|
|
|
|
|
const items = document.createElement("div");
|
|
|
|
|
|
items.className = "admin-card-items";
|
|
|
|
|
|
const total = populateOrderItems(items, order, isEditing);
|
|
|
|
|
|
|
|
|
|
|
|
const footer = document.createElement("div");
|
|
|
|
|
|
footer.className = "admin-card-footer";
|
|
|
|
|
|
const totalWrap = document.createElement("div");
|
|
|
|
|
|
totalWrap.className = "admin-card-total";
|
|
|
|
|
|
const totalLabel = document.createElement("span");
|
|
|
|
|
|
totalLabel.textContent = "Total";
|
|
|
|
|
|
const totalValue = document.createElement("strong");
|
|
|
|
|
|
totalValue.textContent = total;
|
|
|
|
|
|
totalWrap.appendChild(totalLabel);
|
|
|
|
|
|
totalWrap.appendChild(totalValue);
|
|
|
|
|
|
footer.appendChild(totalWrap);
|
|
|
|
|
|
footer.appendChild(
|
|
|
|
|
|
isEditing
|
|
|
|
|
|
? buildEditActions(order.employee_email)
|
|
|
|
|
|
: buildViewActions(order.employee_email, order.employee_name),
|
2026-07-31 10:15:43 -04:00
|
|
|
|
);
|
2026-07-30 19:19:51 -04:00
|
|
|
|
|
2026-07-31 11:33:03 -04:00
|
|
|
|
card.appendChild(header);
|
|
|
|
|
|
card.appendChild(items);
|
|
|
|
|
|
card.appendChild(footer);
|
|
|
|
|
|
return card;
|
|
|
|
|
|
}
|
2026-07-30 19:19:51 -04:00
|
|
|
|
|
2026-07-31 11:33:03 -04:00
|
|
|
|
function renderAdminCards(data) {
|
|
|
|
|
|
const cards = document.getElementById("admin-cards");
|
|
|
|
|
|
cards.innerHTML = "";
|
|
|
|
|
|
(data.orders || []).forEach((order) => {
|
|
|
|
|
|
cards.appendChild(buildAdminCard(order));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-30 19:19:51 -04:00
|
|
|
|
|
2026-07-31 11:33:03 -04:00
|
|
|
|
function renderAdminTable(data) {
|
|
|
|
|
|
lastAdminData = data;
|
|
|
|
|
|
const hasOrders = !!(data.orders && data.orders.length > 0);
|
|
|
|
|
|
const dlBtn = document.getElementById("admin-download-btn");
|
|
|
|
|
|
if (dlBtn) dlBtn.disabled = !hasOrders;
|
|
|
|
|
|
const tbody = document.getElementById("admin-tbody");
|
|
|
|
|
|
tbody.innerHTML = "";
|
|
|
|
|
|
const cards = document.getElementById("admin-cards");
|
|
|
|
|
|
cards.innerHTML = "";
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasOrders) {
|
|
|
|
|
|
document.getElementById("admin-empty").classList.remove("is-hidden");
|
|
|
|
|
|
document.getElementById("admin-stats").textContent = "";
|
|
|
|
|
|
return;
|
2026-07-30 19:19:51 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:33:03 -04:00
|
|
|
|
document.getElementById("admin-empty").classList.add("is-hidden");
|
|
|
|
|
|
const statsEl = document.getElementById("admin-stats");
|
|
|
|
|
|
statsEl.replaceChildren();
|
|
|
|
|
|
const empStrong = document.createElement("strong");
|
|
|
|
|
|
empStrong.textContent = String(data.total_employees);
|
|
|
|
|
|
statsEl.appendChild(empStrong);
|
|
|
|
|
|
statsEl.appendChild(
|
|
|
|
|
|
document.createTextNode(
|
|
|
|
|
|
" employee" + (data.total_employees !== 1 ? "s" : "") + " ordered · ",
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
const totalStrong = document.createElement("strong");
|
|
|
|
|
|
totalStrong.textContent = "$" + data.grand_total.toFixed(2);
|
|
|
|
|
|
statsEl.appendChild(totalStrong);
|
|
|
|
|
|
statsEl.appendChild(document.createTextNode(" total payroll deductions"));
|
|
|
|
|
|
|
|
|
|
|
|
data.orders.forEach((order, idx) => {
|
|
|
|
|
|
const tr = document.createElement("tr");
|
|
|
|
|
|
tr.id = "admin-row-" + idx;
|
|
|
|
|
|
const isEditing = editingOrder === order.employee_email;
|
|
|
|
|
|
|
|
|
|
|
|
const empTd = document.createElement("td");
|
|
|
|
|
|
const nameStrong = document.createElement("strong");
|
|
|
|
|
|
nameStrong.textContent = order.employee_name;
|
|
|
|
|
|
empTd.appendChild(nameStrong);
|
|
|
|
|
|
empTd.appendChild(document.createElement("br"));
|
|
|
|
|
|
const emailSpan = document.createElement("span");
|
|
|
|
|
|
emailSpan.className = "admin-muted";
|
|
|
|
|
|
emailSpan.textContent = order.employee_email;
|
|
|
|
|
|
empTd.appendChild(emailSpan);
|
|
|
|
|
|
|
|
|
|
|
|
const itemsTd = document.createElement("td");
|
|
|
|
|
|
itemsTd.className = "items-cell";
|
|
|
|
|
|
|
|
|
|
|
|
const totalHtml = populateOrderItems(itemsTd, order, isEditing, () =>
|
|
|
|
|
|
document.createElement("br"),
|
|
|
|
|
|
);
|
|
|
|
|
|
tr.appendChild(empTd);
|
|
|
|
|
|
tr.appendChild(itemsTd);
|
|
|
|
|
|
|
|
|
|
|
|
const totalTd = document.createElement("td");
|
|
|
|
|
|
totalTd.className = "total-cell";
|
|
|
|
|
|
totalTd.textContent = totalHtml;
|
|
|
|
|
|
|
|
|
|
|
|
const timeTd = document.createElement("td");
|
|
|
|
|
|
timeTd.className = "time-cell";
|
|
|
|
|
|
timeTd.textContent = formatSubmittedAt(order.submitted_at);
|
|
|
|
|
|
|
|
|
|
|
|
const actionsTd = document.createElement("td");
|
|
|
|
|
|
if (isEditing) {
|
|
|
|
|
|
actionsTd.appendChild(buildEditActions(order.employee_email));
|
2026-07-30 19:19:51 -04:00
|
|
|
|
} else {
|
2026-07-31 11:33:03 -04:00
|
|
|
|
actionsTd.appendChild(
|
|
|
|
|
|
buildViewActions(order.employee_email, order.employee_name),
|
fix(form): accessibility for qty, status, and descriptions (#81)
* fix(form): add status regions, live total, and a11y CSS
Dual alert/status slots for form and admin, aria-live on the sticky
total, success heading focus target, More/Less affordance styles, and
44px coarse-pointer chip padding.
* fix(form): wire a11y labels, status helper, and desc expand
Meal-scoped qty labels at card create time, aria-pressed filter chips,
dual-node showStatus replacing all alert() calls (confirm retained),
overflow-gated More/Less, and success-heading focus after submit.
* test(form): cover a11y labels, status region, and desc toggle
Structural checks for dual status nodes and no alert(); Playwright for
init-time qty labels, aria-pressed chips, overflow More/Less, and
failed-submit text landing in role=alert.
* fix: address review comments
2026-07-31 10:15:43 -04:00
|
|
|
|
);
|
2026-07-30 19:19:51 -04:00
|
|
|
|
}
|
2026-07-31 11:33:03 -04:00
|
|
|
|
|
|
|
|
|
|
tr.appendChild(totalTd);
|
|
|
|
|
|
tr.appendChild(timeTd);
|
|
|
|
|
|
tr.appendChild(actionsTd);
|
|
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const totalRow = document.createElement("tr");
|
|
|
|
|
|
totalRow.className = "admin-total-row";
|
|
|
|
|
|
const mealCount = data.orders.reduce(
|
|
|
|
|
|
(s, o) => s + o.items.reduce((a, i) => a + i.quantity, 0),
|
|
|
|
|
|
0,
|
|
|
|
|
|
);
|
|
|
|
|
|
const t1 = document.createElement("td");
|
|
|
|
|
|
t1.textContent = "Total";
|
|
|
|
|
|
const t2 = document.createElement("td");
|
|
|
|
|
|
t2.textContent = mealCount + (mealCount === 1 ? " meal" : " meals");
|
|
|
|
|
|
const t3 = document.createElement("td");
|
|
|
|
|
|
t3.className = "total-cell";
|
|
|
|
|
|
t3.textContent = "$" + data.grand_total.toFixed(2);
|
|
|
|
|
|
totalRow.appendChild(t1);
|
|
|
|
|
|
totalRow.appendChild(t2);
|
|
|
|
|
|
totalRow.appendChild(t3);
|
|
|
|
|
|
totalRow.appendChild(document.createElement("td"));
|
|
|
|
|
|
totalRow.appendChild(document.createElement("td"));
|
|
|
|
|
|
tbody.appendChild(totalRow);
|
|
|
|
|
|
renderAdminCards(data);
|
2026-07-30 19:19:51 -04:00
|
|
|
|
}
|
2026-07-31 11:33:03 -04:00
|
|
|
|
|
|
|
|
|
|
function downloadOrderList() {
|
|
|
|
|
|
const data = lastAdminData;
|
|
|
|
|
|
if (!data || !data.orders || data.orders.length === 0) return;
|
|
|
|
|
|
const totals = {};
|
|
|
|
|
|
data.orders.forEach((o) =>
|
|
|
|
|
|
(o.items || []).forEach((i) => {
|
|
|
|
|
|
const name = i.name || "";
|
|
|
|
|
|
totals[name] = (totals[name] || 0) + (i.quantity || 0);
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
const names = Object.keys(totals).sort((a, b) => a.localeCompare(b));
|
|
|
|
|
|
const esc = (v) => {
|
|
|
|
|
|
const s = String(v);
|
|
|
|
|
|
const safe = /^[=+\-@\t\r]/.test(s) ? "'" + s : s;
|
|
|
|
|
|
return /[",\n]/.test(safe) ? '"' + safe.replace(/"/g, '""') + '"' : safe;
|
|
|
|
|
|
};
|
|
|
|
|
|
const rows = [["Item", "Quantity"]].concat(
|
|
|
|
|
|
names.map((n) => [n, totals[n]]),
|
|
|
|
|
|
);
|
|
|
|
|
|
const csv = rows.map((r) => r.map(esc).join(",")).join("\r\n");
|
|
|
|
|
|
const week = data.week || currentAdminWeek || WEEK;
|
|
|
|
|
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
a.download = "order-list-" + week + ".csv";
|
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function downloadSummaryPdf() {
|
|
|
|
|
|
const week = currentAdminWeek || WEEK;
|
|
|
|
|
|
const btn = document.getElementById("admin-pdf-btn");
|
|
|
|
|
|
if (btn) btn.disabled = true;
|
|
|
|
|
|
window.FormUtils.clearStatus("admin");
|
|
|
|
|
|
adminFetch(ADMIN_PDF_URL + "?week=" + encodeURIComponent(week), {
|
|
|
|
|
|
headers: adminHeaders(),
|
fix(form): accessibility for qty, status, and descriptions (#81)
* fix(form): add status regions, live total, and a11y CSS
Dual alert/status slots for form and admin, aria-live on the sticky
total, success heading focus target, More/Less affordance styles, and
44px coarse-pointer chip padding.
* fix(form): wire a11y labels, status helper, and desc expand
Meal-scoped qty labels at card create time, aria-pressed filter chips,
dual-node showStatus replacing all alert() calls (confirm retained),
overflow-gated More/Less, and success-heading focus after submit.
* test(form): cover a11y labels, status region, and desc toggle
Structural checks for dual status nodes and no alert(); Playwright for
init-time qty labels, aria-pressed chips, overflow More/Less, and
failed-submit text landing in role=alert.
* fix: address review comments
2026-07-31 10:15:43 -04:00
|
|
|
|
})
|
2026-07-31 11:33:03 -04:00
|
|
|
|
.then((r) =>
|
|
|
|
|
|
r.json().then((data) => ({ ok: r.ok, status: r.status, data })),
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(({ ok, status, data }) => {
|
|
|
|
|
|
if (ok && data.url) {
|
|
|
|
|
|
// Only follow https presigned URLs from our admin API.
|
|
|
|
|
|
if (typeof data.url === "string" && data.url.startsWith("https://")) {
|
|
|
|
|
|
window.location.href = data.url;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
window.FormUtils.showStatus(
|
|
|
|
|
|
"Could not fetch the summary PDF: invalid URL",
|
|
|
|
|
|
{
|
|
|
|
|
|
tone: "error",
|
|
|
|
|
|
scope: "admin",
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (status === 404) {
|
|
|
|
|
|
window.FormUtils.showStatus(
|
|
|
|
|
|
"No summary PDF for " +
|
|
|
|
|
|
week +
|
|
|
|
|
|
" yet — it is generated when the week closes on Thursday.",
|
|
|
|
|
|
{ tone: "info", scope: "admin" },
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
window.FormUtils.showStatus(
|
|
|
|
|
|
"Could not fetch the summary PDF: " +
|
|
|
|
|
|
(data.error || "unknown error"),
|
|
|
|
|
|
{ tone: "error", scope: "admin" },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
window.FormUtils.showStatus("Could not fetch the summary PDF.", {
|
|
|
|
|
|
tone: "error",
|
|
|
|
|
|
scope: "admin",
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
if (btn) btn.disabled = false;
|
fix(form): accessibility for qty, status, and descriptions (#81)
* fix(form): add status regions, live total, and a11y CSS
Dual alert/status slots for form and admin, aria-live on the sticky
total, success heading focus target, More/Less affordance styles, and
44px coarse-pointer chip padding.
* fix(form): wire a11y labels, status helper, and desc expand
Meal-scoped qty labels at card create time, aria-pressed filter chips,
dual-node showStatus replacing all alert() calls (confirm retained),
overflow-gated More/Less, and success-heading focus after submit.
* test(form): cover a11y labels, status region, and desc toggle
Structural checks for dual status nodes and no alert(); Playwright for
init-time qty labels, aria-pressed chips, overflow More/Less, and
failed-submit text landing in role=alert.
* fix: address review comments
2026-07-31 10:15:43 -04:00
|
|
|
|
});
|
2026-07-31 11:33:03 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function adminStartEdit(email) {
|
|
|
|
|
|
window.FormUtils.clearStatus("admin");
|
|
|
|
|
|
const requestedWeek = currentAdminWeek;
|
|
|
|
|
|
setAdminActionDisabled("start-edit", true);
|
|
|
|
|
|
adminFetch(ADMIN_URL + "?week=" + encodeURIComponent(requestedWeek), {
|
|
|
|
|
|
headers: adminHeaders(),
|
fix(form): accessibility for qty, status, and descriptions (#81)
* fix(form): add status regions, live total, and a11y CSS
Dual alert/status slots for form and admin, aria-live on the sticky
total, success heading focus target, More/Less affordance styles, and
44px coarse-pointer chip padding.
* fix(form): wire a11y labels, status helper, and desc expand
Meal-scoped qty labels at card create time, aria-pressed filter chips,
dual-node showStatus replacing all alert() calls (confirm retained),
overflow-gated More/Less, and success-heading focus after submit.
* test(form): cover a11y labels, status region, and desc toggle
Structural checks for dual status nodes and no alert(); Playwright for
init-time qty labels, aria-pressed chips, overflow More/Less, and
failed-submit text landing in role=alert.
* fix: address review comments
2026-07-31 10:15:43 -04:00
|
|
|
|
})
|
2026-07-31 11:33:03 -04:00
|
|
|
|
.then((r) => {
|
|
|
|
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
|
|
|
|
return r.json();
|
|
|
|
|
|
})
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
if (currentAdminWeek !== requestedWeek) return;
|
|
|
|
|
|
lastAdminData = data;
|
|
|
|
|
|
const order = data.orders.find((o) => o.employee_email === email);
|
|
|
|
|
|
if (!order) return;
|
|
|
|
|
|
editingOrder = email;
|
|
|
|
|
|
editQuantities = {};
|
|
|
|
|
|
order.items.forEach((i) => {
|
|
|
|
|
|
editQuantities[i.name] = i.quantity;
|
|
|
|
|
|
});
|
|
|
|
|
|
renderAdminTable(data);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
window.FormUtils.showStatus("Failed to load this order for editing.", {
|
|
|
|
|
|
tone: "error",
|
|
|
|
|
|
scope: "admin",
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => setAdminActionDisabled("start-edit", false));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function adminCancelEdit() {
|
|
|
|
|
|
editingOrder = null;
|
|
|
|
|
|
editQuantities = {};
|
|
|
|
|
|
if (lastAdminData) renderAdminTable(lastAdminData);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function adminEditQty(name, delta) {
|
|
|
|
|
|
const current = editQuantities[name] || 0;
|
|
|
|
|
|
editQuantities[name] = Math.max(0, current + delta);
|
|
|
|
|
|
if (lastAdminData) renderAdminTable(lastAdminData);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function adminAddItem(name) {
|
|
|
|
|
|
if (!name) return;
|
|
|
|
|
|
editQuantities[name] = 1;
|
|
|
|
|
|
if (lastAdminData) renderAdminTable(lastAdminData);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function adminSaveEdit(email) {
|
|
|
|
|
|
const items = Object.entries(editQuantities)
|
|
|
|
|
|
.filter(([, q]) => q > 0)
|
|
|
|
|
|
.map(([name, quantity]) => ({ name, quantity }));
|
|
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
|
// confirm() retained deliberately for destructive zero-qty → delete guard.
|
|
|
|
|
|
if (confirm("All quantities are zero. Delete this order instead?")) {
|
|
|
|
|
|
adminDeleteOrder(email, "");
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
2026-07-30 19:19:51 -04:00
|
|
|
|
}
|
2026-07-31 11:33:03 -04:00
|
|
|
|
window.FormUtils.clearStatus("admin");
|
|
|
|
|
|
setAdminActionDisabled("save-edit", true);
|
|
|
|
|
|
adminFetch(ADMIN_URL, {
|
|
|
|
|
|
method: "PUT",
|
|
|
|
|
|
headers: { ...adminHeaders(), "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ week: currentAdminWeek, email, items }),
|
|
|
|
|
|
})
|
|
|
|
|
|
.then((r) => {
|
|
|
|
|
|
if (!r.ok)
|
|
|
|
|
|
return r.json().then((d) => {
|
|
|
|
|
|
throw new Error(d.error);
|
|
|
|
|
|
});
|
|
|
|
|
|
return r.json();
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
editingOrder = null;
|
|
|
|
|
|
editQuantities = {};
|
|
|
|
|
|
loadWeekOrders(currentAdminWeek);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((e) => {
|
|
|
|
|
|
window.FormUtils.showStatus("Failed to update: " + e.message, {
|
|
|
|
|
|
tone: "error",
|
|
|
|
|
|
scope: "admin",
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => setAdminActionDisabled("save-edit", false));
|
2026-07-30 19:19:51 -04:00
|
|
|
|
}
|
2026-07-31 11:33:03 -04:00
|
|
|
|
|
|
|
|
|
|
function adminDeleteOrder(email, name) {
|
|
|
|
|
|
const label = name ? name + " (" + email + ")" : email;
|
|
|
|
|
|
// confirm() retained deliberately for destructive delete guard.
|
|
|
|
|
|
if (!confirm("Delete order for " + label + "?")) return;
|
|
|
|
|
|
window.FormUtils.clearStatus("admin");
|
|
|
|
|
|
setAdminActionDisabled("delete", true);
|
|
|
|
|
|
adminFetch(
|
|
|
|
|
|
ADMIN_URL +
|
|
|
|
|
|
"?week=" +
|
|
|
|
|
|
encodeURIComponent(currentAdminWeek) +
|
|
|
|
|
|
"&email=" +
|
|
|
|
|
|
encodeURIComponent(email),
|
|
|
|
|
|
{
|
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
|
headers: adminHeaders(),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
.then((r) => {
|
|
|
|
|
|
if (!r.ok)
|
|
|
|
|
|
return r.json().then((d) => {
|
|
|
|
|
|
throw new Error(d.error);
|
|
|
|
|
|
});
|
|
|
|
|
|
return r.json();
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(() => loadWeekOrders(currentAdminWeek))
|
|
|
|
|
|
.catch((e) => {
|
|
|
|
|
|
window.FormUtils.showStatus("Failed to delete: " + e.message, {
|
|
|
|
|
|
tone: "error",
|
|
|
|
|
|
scope: "admin",
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => setAdminActionDisabled("delete", false));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleAdminPanelClick(e) {
|
|
|
|
|
|
const btn = e.target.closest("[data-admin-action]");
|
|
|
|
|
|
if (btn) {
|
|
|
|
|
|
const action = btn.dataset.adminAction;
|
|
|
|
|
|
if (action === "start-edit") {
|
|
|
|
|
|
adminStartEdit(btn.dataset.email);
|
|
|
|
|
|
} else if (action === "delete") {
|
|
|
|
|
|
adminDeleteOrder(btn.dataset.email, btn.dataset.name || "");
|
|
|
|
|
|
} else if (action === "save-edit") {
|
|
|
|
|
|
adminSaveEdit(btn.dataset.email);
|
|
|
|
|
|
} else if (action === "cancel-edit") {
|
|
|
|
|
|
adminCancelEdit();
|
|
|
|
|
|
} else if (action === "edit-qty") {
|
|
|
|
|
|
adminEditQty(btn.dataset.mealName, parseInt(btn.dataset.delta, 10));
|
|
|
|
|
|
} else if (action === "download-list") {
|
|
|
|
|
|
downloadOrderList();
|
|
|
|
|
|
} else if (action === "download-pdf") {
|
|
|
|
|
|
downloadSummaryPdf();
|
|
|
|
|
|
} else if (action === "hide-admin") {
|
|
|
|
|
|
window.hideAdmin();
|
2026-07-30 19:19:51 -04:00
|
|
|
|
}
|
2026-07-31 11:33:03 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-30 19:19:51 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:33:03 -04:00
|
|
|
|
function adminInit() {
|
|
|
|
|
|
const panel = document.getElementById("admin-panel");
|
|
|
|
|
|
if (panel) {
|
|
|
|
|
|
panel.addEventListener("click", handleAdminPanelClick);
|
|
|
|
|
|
panel.addEventListener("change", (e) => {
|
|
|
|
|
|
const select = e.target.closest(
|
|
|
|
|
|
'[data-admin-action="add-item-select"]',
|
|
|
|
|
|
);
|
|
|
|
|
|
if (select && select.value) {
|
|
|
|
|
|
adminAddItem(select.value);
|
|
|
|
|
|
select.selectedIndex = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const weekSelect = document.getElementById("admin-week");
|
|
|
|
|
|
if (weekSelect) {
|
|
|
|
|
|
weekSelect.addEventListener("change", () =>
|
|
|
|
|
|
loadWeekOrders(weekSelect.value),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-30 19:19:51 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 11:33:03 -04:00
|
|
|
|
adminInit();
|
2026-07-30 19:19:51 -04:00
|
|
|
|
})();
|