mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Split the monolithic generate_form f-string into form.html.j2/css/js plus admin.js, inject a single window.CONFIG blob, and add structural plus Playwright coverage so qty delegation and clamp stay green in CI.
414 lines
14 KiB
JavaScript
414 lines
14 KiB
JavaScript
/* 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.innerHTML = '−';
|
||
|
||
const span = document.createElement('span');
|
||
span.textContent = qty;
|
||
|
||
const plusBtn = document.createElement('button');
|
||
plusBtn.type = 'button';
|
||
plusBtn.dataset.adminAction = 'edit-qty';
|
||
plusBtn.dataset.mealName = name;
|
||
plusBtn.dataset.delta = '1';
|
||
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;
|
||
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 {
|
||
alert('Could not fetch the summary PDF: invalid URL');
|
||
}
|
||
} else if (status === 404) {
|
||
alert('No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.');
|
||
} else {
|
||
alert('Could not fetch the summary PDF: ' + (data.error || 'unknown error'));
|
||
}
|
||
})
|
||
.catch(() => alert('Could not fetch the summary PDF.'))
|
||
.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) {
|
||
if (confirm('All quantities are zero. Delete this order instead?')) {
|
||
adminDeleteOrder(email, '');
|
||
}
|
||
return;
|
||
}
|
||
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 => alert('Failed to update: ' + e.message));
|
||
}
|
||
|
||
function adminDeleteOrder(email, name) {
|
||
const label = name ? name + ' (' + email + ')' : email;
|
||
if (!confirm('Delete order for ' + label + '?')) return;
|
||
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 => alert('Failed to delete: ' + e.message));
|
||
}
|
||
|
||
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();
|