mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Compare commits
7 commits
149cc9a0be
...
8c7d8d4723
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c7d8d4723 | |||
| fa52b8c924 | |||
| d5a54b2e78 | |||
| 9f34cb093f | |||
| c0f6316f3f | |||
| 5ba3f00157 | |||
| 91286fc922 |
6 changed files with 819 additions and 52 deletions
|
|
@ -35,11 +35,14 @@ function setAdminActionDisabled(action, 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 = '';
|
||||
|
||||
|
|
@ -56,6 +59,11 @@ function loadWeekOrders(week) {
|
|||
.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',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -71,16 +79,19 @@ function buildEditQtyRow(name, qty) {
|
|||
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);
|
||||
|
|
@ -150,6 +161,112 @@ function buildViewActions(email, name) {
|
|||
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);
|
||||
|
|
@ -157,6 +274,8 @@ function renderAdminTable(data) {
|
|||
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');
|
||||
|
|
@ -209,13 +328,7 @@ function renderAdminTable(data) {
|
|||
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 + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0);
|
||||
}, 0);
|
||||
totalHtml = '$' + newTotal.toFixed(2);
|
||||
totalHtml = '$' + editTotal().toFixed(2);
|
||||
tr.appendChild(empTd);
|
||||
tr.appendChild(itemsTd);
|
||||
} else {
|
||||
|
|
@ -238,15 +351,7 @@ function renderAdminTable(data) {
|
|||
|
||||
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',
|
||||
})
|
||||
: '';
|
||||
timeTd.textContent = formatSubmittedAt(order.submitted_at);
|
||||
|
||||
const actionsTd = document.createElement('td');
|
||||
if (isEditing) {
|
||||
|
|
@ -277,6 +382,7 @@ function renderAdminTable(data) {
|
|||
totalRow.appendChild(document.createElement('td'));
|
||||
totalRow.appendChild(document.createElement('td'));
|
||||
tbody.appendChild(totalRow);
|
||||
renderAdminCards(data);
|
||||
}
|
||||
|
||||
function downloadOrderList() {
|
||||
|
|
@ -311,6 +417,7 @@ 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 }) => {
|
||||
|
|
@ -319,26 +426,43 @@ function downloadSummaryPdf() {
|
|||
if (typeof data.url === 'string' && data.url.startsWith('https://')) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
alert('Could not fetch the summary PDF: invalid URL');
|
||||
window.FormUtils.showStatus('Could not fetch the summary PDF: invalid URL', {
|
||||
tone: 'error',
|
||||
scope: 'admin',
|
||||
});
|
||||
}
|
||||
} else if (status === 404) {
|
||||
alert('No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.');
|
||||
window.FormUtils.showStatus(
|
||||
'No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.',
|
||||
{ tone: 'info', scope: 'admin' }
|
||||
);
|
||||
} else {
|
||||
alert('Could not fetch the summary PDF: ' + (data.error || 'unknown error'));
|
||||
window.FormUtils.showStatus(
|
||||
'Could not fetch the summary PDF: ' + (data.error || 'unknown error'),
|
||||
{ tone: 'error', scope: 'admin' }
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => alert('Could not fetch the summary PDF.'))
|
||||
.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(currentAdminWeek), { headers: adminHeaders() })
|
||||
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;
|
||||
|
|
@ -347,7 +471,12 @@ function adminStartEdit(email) {
|
|||
order.items.forEach(i => { editQuantities[i.name] = i.quantity; });
|
||||
renderAdminTable(data);
|
||||
})
|
||||
.catch(() => alert('Failed to load this order for editing.'))
|
||||
.catch(() => {
|
||||
window.FormUtils.showStatus('Failed to load this order for editing.', {
|
||||
tone: 'error',
|
||||
scope: 'admin',
|
||||
});
|
||||
})
|
||||
.finally(() => setAdminActionDisabled('start-edit', false));
|
||||
}
|
||||
|
||||
|
|
@ -374,11 +503,13 @@ function adminSaveEdit(email) {
|
|||
.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',
|
||||
|
|
@ -387,13 +518,20 @@ function adminSaveEdit(email) {
|
|||
})
|
||||
.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))
|
||||
.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',
|
||||
|
|
@ -401,7 +539,12 @@ function adminDeleteOrder(email, name) {
|
|||
})
|
||||
.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))
|
||||
.catch(e => {
|
||||
window.FormUtils.showStatus('Failed to delete: ' + e.message, {
|
||||
tone: 'error',
|
||||
scope: 'admin',
|
||||
});
|
||||
})
|
||||
.finally(() => setAdminActionDisabled('delete', false));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -152,6 +152,28 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meal-desc.is-expanded {
|
||||
display: block;
|
||||
-webkit-line-clamp: unset;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.meal-desc-toggle {
|
||||
display: inline-block;
|
||||
margin: 0 0 4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-brand);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.meal-desc-toggle:hover { opacity: 0.85; }
|
||||
|
||||
.qty-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -367,6 +389,44 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
.qty-control .qty { width: 44px; height: 44px; }
|
||||
.edit-qty button { width: 44px; height: 44px; }
|
||||
.edit-qty span { width: 44px; height: 44px; line-height: 44px; }
|
||||
.btn-chip { min-height: 44px; padding: 10px 16px; }
|
||||
.admin-order-card .btn,
|
||||
.admin-add-item select { min-height: 44px; }
|
||||
}
|
||||
|
||||
.form-status {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.sticky-footer .form-status {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-panel > .form-status {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.form-status-msg:empty { display: none; }
|
||||
|
||||
.form-status-msg {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-control);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-status-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.form-status-info {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.discount-banner {
|
||||
|
|
@ -454,6 +514,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
border-radius: var(--radius-card);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-card);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
|
|
@ -527,6 +588,64 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
|
||||
.admin-empty { text-align: center; padding: 40px 20px; color: #9ca3af; }
|
||||
|
||||
.admin-cards { display: none; }
|
||||
|
||||
.admin-order-card {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.admin-card-header,
|
||||
.admin-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-card-employee {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-card-time {
|
||||
flex: 0 0 auto;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.admin-card-items {
|
||||
margin: 16px 0;
|
||||
color: #4b5563;
|
||||
font-size: 0.9rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-item + .admin-item { margin-top: 6px; }
|
||||
|
||||
.admin-card-total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-card-total span {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.admin-actions { display: flex; gap: 6px; white-space: nowrap; flex-wrap: wrap; }
|
||||
|
||||
.btn-edit { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
|
||||
|
|
@ -596,3 +715,35 @@ header p { font-size: 0.85rem; opacity: 0.8; }
|
|||
|
||||
.admin-loading { color: #9ca3af; }
|
||||
.admin-error { color: #991b1b; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.admin-panel { padding: 12px; }
|
||||
.admin-table-wrap { display: none; }
|
||||
.admin-cards {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-card-header,
|
||||
.admin-card-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-card-footer .admin-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.admin-card-items .edit-qty {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.admin-card-items .edit-qty button { width: 44px; height: 44px; }
|
||||
.admin-card-items .edit-qty span {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
}
|
||||
.admin-add-item select {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
</div>
|
||||
<div class="qty-control">
|
||||
<button type="button" data-qty-delta="-1" aria-label="Decrease">−</button>
|
||||
<input class="qty" type="number" min="0" step="1" value="0">
|
||||
<input class="qty" type="number" min="0" step="1" value="0" aria-label="Quantity">
|
||||
<button type="button" data-qty-delta="1" aria-label="Increase">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -94,8 +94,12 @@
|
|||
<div id="meals-list"></div>
|
||||
|
||||
<div class="sticky-footer{% if config.authMode == 'google' %} is-hidden{% endif %}" id="sticky-footer">
|
||||
<div class="form-status" id="form-status">
|
||||
<div id="form-status-alert" class="form-status-msg form-status-error" role="alert"></div>
|
||||
<div id="form-status-info" class="form-status-msg form-status-info" role="status"></div>
|
||||
</div>
|
||||
<div class="inner">
|
||||
<div>
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
<span class="total" id="total-display">$0.00</span>
|
||||
<span class="count-display" id="count-display">0 meals</span>
|
||||
</div>
|
||||
|
|
@ -114,6 +118,10 @@
|
|||
<button type="button" class="btn btn-outline back-btn" data-admin-action="hide-admin">Back to Menu</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-status" id="admin-status">
|
||||
<div id="admin-status-alert" class="form-status-msg form-status-error" role="alert"></div>
|
||||
<div id="admin-status-info" class="form-status-msg form-status-info" role="status"></div>
|
||||
</div>
|
||||
<div id="admin-stats" class="admin-stats"></div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
|
|
@ -129,12 +137,13 @@
|
|||
<tbody id="admin-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="admin-cards" class="admin-cards"></div>
|
||||
<div id="admin-empty" class="admin-empty is-hidden">No orders for this week.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="success" class="success-msg is-hidden">
|
||||
<h2>Order submitted!</h2>
|
||||
<h2 id="success-heading" tabindex="-1">Order submitted!</h2>
|
||||
<p id="success-detail"></p>
|
||||
<button type="button" id="success-back-btn" class="btn btn-outline back-btn">Back to Menu</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,32 @@ function employeePrice(retail) {
|
|||
return empPrice;
|
||||
}
|
||||
|
||||
window.FormUtils = { employeePrice };
|
||||
function clearStatus(scope) {
|
||||
const prefix = scope === 'admin' ? 'admin-status' : 'form-status';
|
||||
const alertEl = document.getElementById(prefix + '-alert');
|
||||
const infoEl = document.getElementById(prefix + '-info');
|
||||
if (alertEl) alertEl.textContent = '';
|
||||
if (infoEl) infoEl.textContent = '';
|
||||
}
|
||||
|
||||
function showStatus(message, options) {
|
||||
const opts = options || {};
|
||||
const tone = opts.tone || 'error';
|
||||
const scope = opts.scope || 'form';
|
||||
const prefix = scope === 'admin' ? 'admin-status' : 'form-status';
|
||||
const alertEl = document.getElementById(prefix + '-alert');
|
||||
const infoEl = document.getElementById(prefix + '-info');
|
||||
if (!alertEl || !infoEl) return;
|
||||
if (tone === 'info') {
|
||||
alertEl.textContent = '';
|
||||
infoEl.textContent = message || '';
|
||||
} else {
|
||||
infoEl.textContent = '';
|
||||
alertEl.textContent = message || '';
|
||||
}
|
||||
}
|
||||
|
||||
window.FormUtils = { employeePrice, showStatus, clearStatus };
|
||||
|
||||
function syncGoogleCredential() {
|
||||
window.googleCredential = googleCredential;
|
||||
|
|
@ -184,6 +209,36 @@ function buildMealTagsEl(meal) {
|
|||
return tagsEl;
|
||||
}
|
||||
|
||||
function removeDescToggle(card) {
|
||||
const existing = card.querySelector('.meal-desc-toggle');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
function attachDescToggleIfOverflow(card) {
|
||||
const descEl = card.querySelector('.meal-desc');
|
||||
if (!descEl || descEl.classList.contains('is-hidden')) return;
|
||||
removeDescToggle(card);
|
||||
if (descEl.scrollHeight <= descEl.clientHeight + 1) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'meal-desc-toggle';
|
||||
btn.textContent = 'More';
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.addEventListener('click', () => {
|
||||
const expanded = descEl.classList.toggle('is-expanded');
|
||||
btn.textContent = expanded ? 'Less' : 'More';
|
||||
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
});
|
||||
descEl.after(btn);
|
||||
}
|
||||
|
||||
function scheduleDescToggle(card) {
|
||||
requestAnimationFrame(() => {
|
||||
attachDescToggleIfOverflow(card);
|
||||
});
|
||||
}
|
||||
|
||||
function updateMealCard(card, meal, i) {
|
||||
const qty = quantities[i] || 0;
|
||||
card.dataset.mealIndex = String(i);
|
||||
|
|
@ -203,9 +258,12 @@ function updateMealCard(card, meal, i) {
|
|||
card.querySelector('.meal-name').textContent = meal.name;
|
||||
|
||||
const descEl = card.querySelector('.meal-desc');
|
||||
removeDescToggle(card);
|
||||
descEl.classList.remove('is-expanded');
|
||||
if (meal.description) {
|
||||
descEl.textContent = meal.description;
|
||||
descEl.classList.remove('is-hidden');
|
||||
scheduleDescToggle(card);
|
||||
} else {
|
||||
descEl.textContent = '';
|
||||
descEl.classList.add('is-hidden');
|
||||
|
|
@ -221,6 +279,12 @@ function updateMealCard(card, meal, i) {
|
|||
|
||||
const qtyInput = card.querySelector('.qty');
|
||||
qtyInput.value = qty;
|
||||
qtyInput.setAttribute('aria-label', 'Quantity of ' + meal.name);
|
||||
|
||||
const minusBtn = card.querySelector('[data-qty-delta="-1"]');
|
||||
const plusBtn = card.querySelector('[data-qty-delta="1"]');
|
||||
if (minusBtn) minusBtn.setAttribute('aria-label', 'Remove one ' + meal.name);
|
||||
if (plusBtn) plusBtn.setAttribute('aria-label', 'Add one ' + meal.name);
|
||||
|
||||
const disabled = formClosed && !isAdmin;
|
||||
card.querySelectorAll('[data-qty-delta]').forEach(btn => { btn.disabled = disabled; });
|
||||
|
|
@ -323,20 +387,38 @@ function updateTotal() {
|
|||
}
|
||||
|
||||
async function submitOrder() {
|
||||
if (formClosed && !isAdmin) { alert('Orders are closed.'); return; }
|
||||
clearStatus('form');
|
||||
|
||||
if (formClosed && !isAdmin) {
|
||||
showStatus('Orders are closed.', { tone: 'error', scope: 'form' });
|
||||
return;
|
||||
}
|
||||
|
||||
let payload;
|
||||
let successName;
|
||||
|
||||
if (CONFIG.authMode === 'google') {
|
||||
if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; }
|
||||
if (!googleCredential || !googleUser) {
|
||||
showStatus('Please sign in with Google first.', { tone: 'error', scope: 'form' });
|
||||
return;
|
||||
}
|
||||
payload = { google_id_token: googleCredential, items: buildItems() };
|
||||
successName = googleUser.name;
|
||||
} else {
|
||||
const name = document.getElementById('emp-name').value.trim();
|
||||
const email = document.getElementById('emp-email').value.trim();
|
||||
if (!name) { alert('Please enter your name.'); return; }
|
||||
if (!email) { alert('Please enter your email.'); return; }
|
||||
const nameEl = document.getElementById('emp-name');
|
||||
const emailEl = document.getElementById('emp-email');
|
||||
const name = nameEl.value.trim();
|
||||
const email = emailEl.value.trim();
|
||||
if (!name) {
|
||||
showStatus('Please enter your name.', { tone: 'error', scope: 'form' });
|
||||
nameEl.focus();
|
||||
return;
|
||||
}
|
||||
if (!email) {
|
||||
showStatus('Please enter your email.', { tone: 'error', scope: 'form' });
|
||||
emailEl.focus();
|
||||
return;
|
||||
}
|
||||
payload = { employee_name: name, employee_email: email, items: buildItems() };
|
||||
successName = name;
|
||||
}
|
||||
|
|
@ -364,18 +446,42 @@ async function submitOrder() {
|
|||
const footer = document.querySelector('.sticky-footer');
|
||||
if (footer) footer.classList.add('is-hidden');
|
||||
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
|
||||
const successHeading = document.getElementById('success-heading');
|
||||
if (successHeading) successHeading.focus();
|
||||
} else {
|
||||
alert(data.error || 'Something went wrong.');
|
||||
showStatus(data.error || 'Something went wrong.', { tone: 'error', scope: 'form' });
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to submit. Check your connection and try again.');
|
||||
showStatus('Failed to submit. Check your connection and try again.', { tone: 'error', scope: 'form' });
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}
|
||||
}
|
||||
|
||||
function bindEmpNameChange(select) {
|
||||
if (select.dataset.boundChange === '1') return;
|
||||
select.dataset.boundChange = '1';
|
||||
select.addEventListener('change', () => {
|
||||
const selected = select.options[select.selectedIndex];
|
||||
document.getElementById('emp-email').value = selected?.dataset?.email || '';
|
||||
clearStatus('form');
|
||||
});
|
||||
}
|
||||
|
||||
function populateEmpSelect(select, employees) {
|
||||
select.innerHTML = '<option value="">Select your name</option>';
|
||||
employees.forEach(emp => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = emp.name;
|
||||
opt.dataset.email = emp.email;
|
||||
opt.textContent = emp.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
bindEmpNameChange(select);
|
||||
}
|
||||
|
||||
async function loadRoster() {
|
||||
let employees = ROSTER;
|
||||
try {
|
||||
|
|
@ -390,18 +496,7 @@ async function loadRoster() {
|
|||
} catch (e) {}
|
||||
|
||||
const select = document.getElementById('emp-name');
|
||||
select.innerHTML = '<option value="">Select your name</option>';
|
||||
employees.forEach(emp => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = emp.name;
|
||||
opt.dataset.email = emp.email;
|
||||
opt.textContent = emp.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
select.addEventListener('change', () => {
|
||||
const selected = select.options[select.selectedIndex];
|
||||
document.getElementById('emp-email').value = selected?.dataset?.email || '';
|
||||
});
|
||||
populateEmpSelect(select, employees);
|
||||
}
|
||||
|
||||
function showClosedOverlay() {
|
||||
|
|
@ -588,11 +683,17 @@ function init() {
|
|||
cats.forEach(cat => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-chip' + (cat === 'All Meals' ? ' active' : '');
|
||||
const isActive = cat === 'All Meals';
|
||||
btn.className = 'btn btn-chip' + (isActive ? ' active' : '');
|
||||
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||
btn.textContent = cat;
|
||||
btn.addEventListener('click', () => {
|
||||
filtersEl.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
||||
filtersEl.querySelectorAll('button').forEach(b => {
|
||||
b.classList.remove('active');
|
||||
b.setAttribute('aria-pressed', 'false');
|
||||
});
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-pressed', 'true');
|
||||
renderMeals();
|
||||
});
|
||||
filtersEl.appendChild(btn);
|
||||
|
|
|
|||
2
tests/fixtures/menu.json
vendored
2
tests/fixtures/menu.json
vendored
|
|
@ -19,7 +19,7 @@
|
|||
"price": 14.5,
|
||||
"calories": "620",
|
||||
"protein": "35g",
|
||||
"description": "With onions",
|
||||
"description": "Stir-fried beef tenderloin with onions, tomatoes, and soy sauce, served over french fries with a side of steamed white rice and aji amarillo. A long fixture blurb used to exercise the three-line clamp and the More expand control in accessibility tests.",
|
||||
"dietary_tags": [],
|
||||
"image_url": "",
|
||||
"is_new": true
|
||||
|
|
|
|||
|
|
@ -101,6 +101,14 @@ def _mock_form_routes(page):
|
|||
),
|
||||
),
|
||||
)
|
||||
page.route(
|
||||
"**/api/admin/orders",
|
||||
lambda route: route.fulfill(
|
||||
status=403,
|
||||
content_type="application/json",
|
||||
body='{"error":"Forbidden"}',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateFormStructural:
|
||||
|
|
@ -198,6 +206,20 @@ class TestGenerateFormStructural:
|
|||
# Static markup must not interpolate runtime data into onclick
|
||||
assert not re.search(r'onclick="[^"]*\$\{', html)
|
||||
|
||||
def test_mobile_admin_card_structure_and_breakpoint(self):
|
||||
html = _render()
|
||||
css = (SERVER_DIR / "templates" / "form.css").read_text()
|
||||
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
|
||||
|
||||
assert 'id="admin-cards"' in html
|
||||
assert 'id="admin-status-alert"' in html
|
||||
assert 'id="admin-status-info"' in html
|
||||
assert "@media (max-width: 767px)" in css
|
||||
assert ".admin-table-wrap { display: none; }" in css
|
||||
assert "buildAdminCard" in admin_js
|
||||
assert "dataset.adminAction" in admin_js
|
||||
assert "confirm(" in admin_js
|
||||
|
||||
def test_script_breakout_meal_escaped(self):
|
||||
html = _render()
|
||||
assert "</script><script>alert(1)</script>" not in html
|
||||
|
|
@ -227,6 +249,26 @@ class TestGenerateFormStructural:
|
|||
assert "data-meal-index" in html
|
||||
assert "data-qty-delta" in html
|
||||
|
||||
def test_a11y_status_and_live_total_markup(self):
|
||||
html = _render()
|
||||
assert 'id="form-status-alert"' in html
|
||||
assert 'id="form-status-info"' in html
|
||||
assert 'id="admin-status-alert"' in html
|
||||
assert 'id="admin-status-info"' in html
|
||||
assert 'role="alert"' in html
|
||||
assert 'role="status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert 'id="success-heading"' in html
|
||||
assert 'tabindex="-1"' in html
|
||||
|
||||
def test_no_alert_dialogs_in_form_scripts(self):
|
||||
form_js = (SERVER_DIR / "templates" / "form.js").read_text()
|
||||
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
|
||||
assert "alert(" not in form_js
|
||||
assert "alert(" not in admin_js
|
||||
# confirm() retained for destructive admin delete / zero-qty save.
|
||||
assert "confirm(" in admin_js
|
||||
|
||||
def test_design_tokens_only_in_definitions(self):
|
||||
css = (SERVER_DIR / "templates" / "form.css").read_text()
|
||||
# Strip :root block, then assert brand/muted/border hexes are gone
|
||||
|
|
@ -357,10 +399,15 @@ class TestGenerateFormPlaywright:
|
|||
bottom: box.bottom,
|
||||
};
|
||||
};
|
||||
const footer = document.querySelector('#sticky-footer');
|
||||
return {
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
footer: rect('#sticky-footer'),
|
||||
viewportHeight: window.innerHeight,
|
||||
footer: {
|
||||
...rect('#sticky-footer'),
|
||||
position: getComputedStyle(footer).position,
|
||||
},
|
||||
total: rect('#total-display'),
|
||||
count: rect('#count-display'),
|
||||
submit: rect('#submit-btn'),
|
||||
|
|
@ -373,6 +420,9 @@ class TestGenerateFormPlaywright:
|
|||
for selector in ("footer", "total", "count", "submit"):
|
||||
assert layout[selector]["left"] >= 0
|
||||
assert layout[selector]["right"] <= layout["clientWidth"]
|
||||
assert layout["footer"]["position"] == "fixed"
|
||||
assert layout["footer"]["top"] >= 0
|
||||
assert layout["footer"]["bottom"] <= layout["viewportHeight"]
|
||||
assert layout["lastCard"]["bottom"] <= layout["footer"]["top"]
|
||||
finally:
|
||||
browser.close()
|
||||
|
|
@ -414,6 +464,111 @@ class TestGenerateFormPlaywright:
|
|||
assert page.locator("#count-display").inner_text() == "2 meals"
|
||||
assert page.locator("#total-display").inner_text() == "$10.80"
|
||||
|
||||
def test_qty_labels_present_after_init(self, browser_page):
|
||||
page = browser_page
|
||||
card = page.locator('[data-meal-index="0"]')
|
||||
assert (
|
||||
card.locator('[data-qty-delta="-1"]').get_attribute("aria-label")
|
||||
== "Remove one Chicken Bowl"
|
||||
)
|
||||
assert (
|
||||
card.locator('[data-qty-delta="1"]').get_attribute("aria-label")
|
||||
== "Add one Chicken Bowl"
|
||||
)
|
||||
assert (
|
||||
card.locator(".qty").get_attribute("aria-label")
|
||||
== "Quantity of Chicken Bowl"
|
||||
)
|
||||
|
||||
def test_filter_chip_aria_pressed(self, browser_page):
|
||||
page = browser_page
|
||||
all_btn = page.locator("#filters button", has_text="All Meals")
|
||||
assert all_btn.get_attribute("aria-pressed") == "true"
|
||||
other = page.locator("#filters button").nth(1)
|
||||
other_label = other.inner_text()
|
||||
other.click()
|
||||
page.wait_for_function(
|
||||
"""(label) => {
|
||||
const active = document.querySelector('#filters button.active');
|
||||
return active && active.textContent === label
|
||||
&& active.getAttribute('aria-pressed') === 'true';
|
||||
}""",
|
||||
arg=other_label,
|
||||
)
|
||||
assert all_btn.get_attribute("aria-pressed") == "false"
|
||||
assert other.get_attribute("aria-pressed") == "true"
|
||||
all_btn.click()
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const active = document.querySelector('#filters button.active');
|
||||
return active && active.textContent === 'All Meals'
|
||||
&& active.getAttribute('aria-pressed') === 'true';
|
||||
}"""
|
||||
)
|
||||
|
||||
def test_desc_more_only_when_overflow(self, browser_page):
|
||||
page = browser_page
|
||||
# Ensure All Meals filter so both short and long cards are visible.
|
||||
page.locator("#filters button", has_text="All Meals").click()
|
||||
page.wait_for_selector('[data-meal-index="0"]')
|
||||
page.wait_for_selector('[data-meal-index="1"]')
|
||||
short_card = page.locator('[data-meal-index="0"]')
|
||||
long_card = page.locator('[data-meal-index="1"]')
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const long = document.querySelector('[data-meal-index="1"] .meal-desc-toggle');
|
||||
return !!long;
|
||||
}"""
|
||||
)
|
||||
assert short_card.locator(".meal-desc-toggle").count() == 0
|
||||
toggle = long_card.locator(".meal-desc-toggle")
|
||||
assert toggle.inner_text() == "More"
|
||||
assert toggle.get_attribute("aria-expanded") == "false"
|
||||
toggle.click()
|
||||
assert toggle.inner_text() == "Less"
|
||||
assert toggle.get_attribute("aria-expanded") == "true"
|
||||
assert "is-expanded" in (
|
||||
long_card.locator(".meal-desc").get_attribute("class") or ""
|
||||
)
|
||||
|
||||
def test_submit_failure_lands_in_alert_region(self, browser_page):
|
||||
page = browser_page
|
||||
page.route(
|
||||
"**/api/submit-order**",
|
||||
lambda route: route.fulfill(
|
||||
status=500,
|
||||
content_type="application/json",
|
||||
body='{"error":"Simulated submit failure"}',
|
||||
),
|
||||
)
|
||||
page.locator("#emp-name").select_option(label="Ada Lovelace")
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelectorAll('[data-meal-index] .qty').forEach((input) => {
|
||||
input.value = '0';
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}"""
|
||||
)
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('count-display').textContent === '0 meals'"
|
||||
)
|
||||
page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click()
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('count-display').textContent === '1 meal'"
|
||||
)
|
||||
page.locator("#submit-btn").click()
|
||||
page.wait_for_function(
|
||||
"""() => document.getElementById('form-status-alert').textContent
|
||||
=== 'Simulated submit failure'"""
|
||||
)
|
||||
assert (
|
||||
page.locator("#form-status-alert").inner_text()
|
||||
== "Simulated submit failure"
|
||||
)
|
||||
assert page.locator("#form-status-info").inner_text() == ""
|
||||
page.unroute("**/api/submit-order**")
|
||||
|
||||
def test_qty_typein_clamps_negative(self, browser_page):
|
||||
page = browser_page
|
||||
# Reset first card via type-in
|
||||
|
|
@ -443,6 +598,214 @@ class TestGenerateFormPlaywright:
|
|||
)
|
||||
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
|
||||
|
||||
@pytest.fixture
|
||||
def mobile_admin_page(self, tmp_path, browser_page):
|
||||
html = _render(google=False, api_key="")
|
||||
out = tmp_path / "admin-order-form.html"
|
||||
out.write_text(html)
|
||||
hostile_name = """O'Reilly "<b>Ops</b>\""""
|
||||
state = {
|
||||
"fail_put": False,
|
||||
"dialogs": [],
|
||||
"orders": [
|
||||
{
|
||||
"employee_name": hostile_name,
|
||||
"employee_email": "hostile@example.com",
|
||||
"submitted_at": "2026-07-30T14:15:00Z",
|
||||
"items": [
|
||||
{
|
||||
"name": "Chicken Bowl",
|
||||
"quantity": 1,
|
||||
"subtotal": 12.0,
|
||||
}
|
||||
],
|
||||
"total": 12.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
prices = {
|
||||
"Chicken Bowl": 12.0,
|
||||
"Luis' Lomo Saltado": 14.5,
|
||||
'Meal with "quotes"': 11.0,
|
||||
"</script><script>alert(1)</script>": 9.0,
|
||||
}
|
||||
|
||||
def admin_payload():
|
||||
return {
|
||||
"week": "2026-W30",
|
||||
"orders": state["orders"],
|
||||
"total_employees": len(state["orders"]),
|
||||
"grand_total": sum(order["total"] for order in state["orders"]),
|
||||
}
|
||||
|
||||
def handle_admin(route):
|
||||
request = route.request
|
||||
if request.method == "PUT":
|
||||
if state["fail_put"]:
|
||||
route.fulfill(
|
||||
status=500,
|
||||
content_type="application/json",
|
||||
body='{"error":"hostile update failed"}',
|
||||
)
|
||||
return
|
||||
payload = request.post_data_json
|
||||
order = state["orders"][0]
|
||||
order["items"] = [
|
||||
{
|
||||
"name": item["name"],
|
||||
"quantity": item["quantity"],
|
||||
"subtotal": prices[item["name"]] * item["quantity"],
|
||||
}
|
||||
for item in payload["items"]
|
||||
]
|
||||
order["total"] = sum(item["subtotal"] for item in order["items"])
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"ok":true}',
|
||||
)
|
||||
return
|
||||
if request.method == "DELETE":
|
||||
state["orders"] = []
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"ok":true}',
|
||||
)
|
||||
return
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(admin_payload()),
|
||||
)
|
||||
|
||||
browser = browser_page.context.browser
|
||||
assert browser is not None
|
||||
context = browser.new_context(
|
||||
viewport={"width": 375, "height": 800},
|
||||
has_touch=True,
|
||||
is_mobile=True,
|
||||
)
|
||||
page = context.new_page()
|
||||
page.route(
|
||||
"**/api/form-status/**",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"status":"open"}',
|
||||
),
|
||||
)
|
||||
page.route(
|
||||
"**/api/roster",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"employees":[]}',
|
||||
),
|
||||
)
|
||||
page.route("**/api/admin/orders**", handle_admin)
|
||||
page.on(
|
||||
"dialog",
|
||||
lambda dialog: (
|
||||
state["dialogs"].append(dialog.message),
|
||||
dialog.accept(),
|
||||
),
|
||||
)
|
||||
page.goto(out.as_uri())
|
||||
page.evaluate(
|
||||
"() => { window.showAdmin(); window.loadWeekOrders('2026-W30'); }"
|
||||
)
|
||||
page.wait_for_selector(".admin-order-card")
|
||||
yield page, state, hostile_name
|
||||
context.close()
|
||||
|
||||
def test_mobile_admin_card_complete_workflow(self, mobile_admin_page):
|
||||
page, state, hostile_name = mobile_admin_page
|
||||
card = page.locator(".admin-order-card")
|
||||
|
||||
assert card.is_visible()
|
||||
assert not page.locator(".admin-table-wrap").is_visible()
|
||||
assert hostile_name in card.inner_text()
|
||||
assert "hostile@example.com" in card.inner_text()
|
||||
assert "Chicken Bowl ×1" in card.inner_text()
|
||||
assert "$12.00" in card.inner_text()
|
||||
assert page.evaluate(
|
||||
"() => document.documentElement.scrollWidth === "
|
||||
"document.documentElement.clientWidth"
|
||||
)
|
||||
|
||||
card.locator('[data-admin-action="start-edit"]').click()
|
||||
page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]')
|
||||
minus = page.locator(
|
||||
'.admin-order-card [data-admin-action="edit-qty"][data-delta="-1"]'
|
||||
)
|
||||
assert minus.get_attribute("aria-label") == "Remove one Chicken Bowl"
|
||||
assert minus.evaluate("(el) => el.getBoundingClientRect().height") >= 44
|
||||
|
||||
add_item = page.locator(
|
||||
'.admin-order-card [data-admin-action="add-item-select"]'
|
||||
)
|
||||
add_item.select_option(label="Luis' Lomo Saltado ($14.50)")
|
||||
page.wait_for_selector(
|
||||
'.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"]'
|
||||
)
|
||||
page.locator(
|
||||
'.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"][data-delta="1"]'
|
||||
).click()
|
||||
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
|
||||
page.wait_for_function(
|
||||
"() => document.querySelector('.admin-order-card')"
|
||||
'.textContent.includes("Luis\' Lomo Saltado ×2")'
|
||||
)
|
||||
assert state["orders"][0]["employee_name"] == hostile_name
|
||||
assert state["orders"][0]["total"] == 41.0
|
||||
|
||||
page.locator('.admin-order-card [data-admin-action="start-edit"]').click()
|
||||
page.wait_for_selector('.admin-order-card [data-admin-action="cancel-edit"]')
|
||||
page.locator(
|
||||
'.admin-order-card [data-admin-action="edit-qty"][data-delta="1"]'
|
||||
).first.click()
|
||||
page.locator('.admin-order-card [data-admin-action="cancel-edit"]').click()
|
||||
assert page.locator(
|
||||
'.admin-order-card [data-admin-action="start-edit"]'
|
||||
).is_visible()
|
||||
|
||||
for width in (320, 375, 767):
|
||||
page.set_viewport_size({"width": width, "height": 800})
|
||||
assert page.locator(".admin-order-card").is_visible()
|
||||
assert page.evaluate(
|
||||
"() => document.documentElement.scrollWidth === "
|
||||
"document.documentElement.clientWidth"
|
||||
)
|
||||
assert page.evaluate(
|
||||
"""() => Array.from(document.querySelectorAll('#admin-panel *'))
|
||||
.filter((el) => el.offsetParent !== null)
|
||||
.every((el) => el.scrollWidth <= el.clientWidth + 1)"""
|
||||
)
|
||||
|
||||
page.set_viewport_size({"width": 768, "height": 800})
|
||||
assert page.locator(".admin-table-wrap").is_visible()
|
||||
assert not page.locator(".admin-order-card").is_visible()
|
||||
|
||||
page.set_viewport_size({"width": 375, "height": 800})
|
||||
state["fail_put"] = True
|
||||
page.locator('.admin-order-card [data-admin-action="start-edit"]').click()
|
||||
page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]')
|
||||
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('admin-status-alert').textContent"
|
||||
".includes('hostile update failed')"
|
||||
)
|
||||
state["fail_put"] = False
|
||||
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
|
||||
page.wait_for_selector('.admin-order-card [data-admin-action="start-edit"]')
|
||||
assert page.locator("#admin-status-alert").inner_text() == ""
|
||||
|
||||
page.locator('.admin-order-card [data-admin-action="delete"]').click()
|
||||
page.wait_for_selector("#admin-empty:not(.is-hidden)")
|
||||
assert hostile_name in state["dialogs"][-1]
|
||||
assert state["orders"] == []
|
||||
|
||||
|
||||
class TestGenerateFormGooglePlaywright:
|
||||
@pytest.fixture(scope="class")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue