meal-order-manager/src/server/templates/admin.js

594 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 = '<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);
}
})
.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 = '&minus;';
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();
})();