diff --git a/src/server/templates/admin.js b/src/server/templates/admin.js
index c7470c8..897a5da 100644
--- a/src/server/templates/admin.js
+++ b/src/server/templates/admin.js
@@ -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 = '
| Loading... |
';
+ const cards = document.getElementById('admin-cards');
+ cards.innerHTML = 'Loading...
';
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 = '| Failed to load orders. |
';
+ cards.innerHTML = '';
+ window.FormUtils.showStatus('Failed to load orders.', {
+ tone: 'error',
+ scope: 'admin',
+ });
}
});
}
@@ -153,6 +161,116 @@ 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, separatorFactory) {
+ if (isEditing) {
+ Object.keys(editQuantities).forEach(name => {
+ container.appendChild(buildEditQtyRow(name, editQuantities[name] || 0));
+ if (separatorFactory) container.appendChild(separatorFactory());
+ });
+ 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, itemIdx) => {
+ if (separatorFactory && itemIdx > 0) {
+ container.appendChild(separatorFactory());
+ }
+ const row = separatorFactory ? container : document.createElement('div');
+ if (!separatorFactory) 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);
+ if (!separatorFactory) 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);
@@ -160,6 +278,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');
@@ -201,39 +321,14 @@ function renderAdminTable(data) {
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 + window.FormUtils.employeePrice(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 totalHtml = populateOrderItems(
+ itemsTd,
+ order,
+ isEditing,
+ () => document.createElement('br')
+ );
+ tr.appendChild(empTd);
+ tr.appendChild(itemsTd);
const totalTd = document.createElement('td');
totalTd.className = 'total-cell';
@@ -241,15 +336,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) {
@@ -280,6 +367,7 @@ function renderAdminTable(data) {
totalRow.appendChild(document.createElement('td'));
totalRow.appendChild(document.createElement('td'));
tbody.appendChild(totalRow);
+ renderAdminCards(data);
}
function downloadOrderList() {
diff --git a/src/server/templates/form.css b/src/server/templates/form.css
index 0e35cd9..a7d832c 100644
--- a/src/server/templates/form.css
+++ b/src/server/templates/form.css
@@ -390,6 +390,8 @@ header p { font-size: 0.85rem; opacity: 0.8; }
.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 {
@@ -512,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 {
@@ -585,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; }
@@ -654,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%;
+ }
+}
diff --git a/src/server/templates/form.html.j2 b/src/server/templates/form.html.j2
index 0f1a324..f9510cb 100644
--- a/src/server/templates/form.html.j2
+++ b/src/server/templates/form.html.j2
@@ -137,6 +137,7 @@
+
No orders for this week.
diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py
index 08a64c8..056b3f3 100644
--- a/tests/test_generate_form.py
+++ b/tests/test_generate_form.py
@@ -206,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 "" not in html
@@ -584,6 +598,240 @@ 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 "Ops\""""
+ 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,
+ "": 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,
+ )
+ try:
+ 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()
+ if state.get("accept_dialogs", True)
+ else dialog.dismiss(),
+ ),
+ )
+ 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
+ finally:
+ context.close()
+
+ def test_mobile_admin_card_initial_render_and_overflow(self, mobile_admin_page):
+ page, _, 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() # noqa: RUF001
+ assert "$12.00" in card.inner_text()
+ assert page.evaluate(
+ "() => document.documentElement.scrollWidth === "
+ "document.documentElement.clientWidth"
+ )
+
+ def test_mobile_admin_card_edit_and_save(self, mobile_admin_page):
+ page, state, hostile_name = mobile_admin_page
+ card = page.locator(".admin-order-card")
+
+ 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")' # noqa: RUF001
+ )
+ assert state["orders"][0]["employee_name"] == hostile_name
+ assert state["orders"][0]["total"] == 41.0
+
+ def test_mobile_admin_card_cancel(self, mobile_admin_page):
+ page, _, _ = mobile_admin_page
+
+ 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()
+
+ def test_mobile_admin_card_responsive_breakpoints(self, mobile_admin_page):
+ page, _, _ = mobile_admin_page
+
+ 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()
+
+ def test_mobile_admin_card_save_failure_and_retry(self, mobile_admin_page):
+ page, state, _ = mobile_admin_page
+
+ 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() == ""
+
+ def test_mobile_admin_card_delete(self, mobile_admin_page):
+ page, state, hostile_name = mobile_admin_page
+
+ orders_before = list(state["orders"])
+ state["accept_dialogs"] = False
+ page.locator('.admin-order-card [data-admin-action="delete"]').click()
+ assert page.locator(".admin-order-card").is_visible()
+ assert state["orders"] == orders_before
+
+ state["accept_dialogs"] = True
+ 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")