/* 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'); const requestedWeek = currentAdminWeek; setAdminActionDisabled('start-edit', true); adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(requestedWeek), { headers: adminHeaders() }) .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; } 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(); })();