/* ADMIN_JS_MARKER */
(() => {
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;
});
}
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 = '
| Loading... |
';
const cards = document.getElementById('admin-cards');
cards.innerHTML = 'Loading...
';
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);
}
})
.catch(() => {
if (requestId === latestAdminRequest && week === currentAdminWeek) {
tbody.innerHTML = '| Failed to load orders. |
';
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);
});
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;
});
}
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',
})
: '';
}
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) {
if (isEditing) {
Object.keys(editQuantities).forEach(name => {
container.appendChild(buildEditQtyRow(name, editQuantities[name] || 0));
});
const availableMeals = MEALS.filter(
m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name)
);
if (availableMeals.length > 0) {
container.appendChild(buildAddItemSelect(availableMeals));
}
return '$' + editTotal().toFixed(2);
}
order.items.forEach(item => {
const row = document.createElement('div');
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);
container.appendChild(row);
});
return '$' + order.total.toFixed(2);
}
function buildAdminCard(order) {
const card = document.createElement('article');
card.className = 'admin-order-card';
card.dataset.email = order.employee_email;
const isEditing = editingOrder === order.employee_email;
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)
);
card.appendChild(header);
card.appendChild(items);
card.appendChild(footer);
return card;
}
function renderAdminCards(data) {
const cards = document.getElementById('admin-cards');
cards.innerHTML = '';
(data.orders || []).forEach(order => {
cards.appendChild(buildAdminCard(order));
});
}
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;
}
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';
let totalHtml;
if (isEditing) {
const editNames = Object.keys(editQuantities);
editNames.forEach(name => {
itemsTd.appendChild(buildEditQtyRow(name, editQuantities[name] || 0));
itemsTd.appendChild(document.createElement('br'));
});
const availableMeals = MEALS.filter(m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name));
if (availableMeals.length > 0) {
itemsTd.appendChild(buildAddItemSelect(availableMeals));
}
totalHtml = '$' + editTotal().toFixed(2);
tr.appendChild(empTd);
tr.appendChild(itemsTd);
} else {
order.items.forEach((item, itemIdx) => {
if (itemIdx > 0) itemsTd.appendChild(document.createElement('br'));
itemsTd.appendChild(document.createTextNode(item.name + ' ×' + item.quantity + ' '));
const sub = document.createElement('span');
sub.className = 'admin-muted';
sub.textContent = '($' + item.subtotal.toFixed(2) + ')';
itemsTd.appendChild(sub);
});
totalHtml = '$' + order.total.toFixed(2);
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));
} else {
actionsTd.appendChild(buildViewActions(order.employee_email, order.employee_name));
}
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);
}
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() })
.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; });
}
function adminStartEdit(email) {
window.FormUtils.clearStatus('admin');
setAdminActionDisabled('start-edit', true);
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() })
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(data => {
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;
}
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));
}
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();
}
return;
}
}
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));
}
}
adminInit();
})();