meal-order-manager/src/server/templates/admin.js
2026-07-30 18:39:55 -04:00

448 lines
15 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;
let currentAdminWeek = '';
let editingOrder = null;
let editQuantities = {};
let lastAdminData = null;
function adminHeaders() {
const h = {};
const cred = window.googleCredential;
if (cred) h['Authorization'] = 'Bearer ' + cred;
return h;
}
function loadWeekOrders(week) {
if (!week) return;
currentAdminWeek = week;
editingOrder = null;
editQuantities = {};
const tbody = document.getElementById('admin-tbody');
tbody.innerHTML = '<tr><td colspan="5" class="admin-loading">Loading...</td></tr>';
document.getElementById('admin-empty').classList.add('is-hidden');
document.getElementById('admin-stats').textContent = '';
fetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
.then(r => r.json())
.then(data => renderAdminTable(data))
.catch(() => {
tbody.innerHTML = '<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
});
}
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');
opt.value = m.name;
opt.textContent = m.name + ' ($' + m.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(idx, email, name) {
const wrap = document.createElement('div');
wrap.className = 'admin-actions';
const editBtn = buildActionButton('Edit', 'start-edit', { index: String(idx), 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 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 = '';
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));
}
const menuPrices = {};
MEALS.forEach(m => { menuPrices[m.name] = m.price; });
const newTotal = editNames.reduce((sum, name) => {
const price = menuPrices[name] || 0;
return sum + price * (editQuantities[name] || 0);
}, 0);
totalHtml = '$' + newTotal.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 = order.submitted_at
? new Date(order.submitted_at).toLocaleString('en-US', {
timeZone: 'America/New_York',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
: '';
const actionsTd = document.createElement('td');
if (isEditing) {
actionsTd.appendChild(buildEditActions(order.employee_email));
} else {
actionsTd.appendChild(buildViewActions(idx, 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 + ' 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);
}
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);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
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');
fetch(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(idx, email) {
fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() })
.then(r => 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);
});
}
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');
fetch(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',
});
});
}
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');
fetch(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',
});
});
}
function handleAdminPanelClick(e) {
const btn = e.target.closest('[data-admin-action]');
if (btn) {
const action = btn.dataset.adminAction;
if (action === 'start-edit') {
adminStartEdit(parseInt(btn.dataset.index, 10), 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();
})();