mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 07:43:13 +00:00
Stop phone-width sideways scroll from the admin toolbar, stack meal cards and bump touch targets on coarse pointers, and respect safe-area insets.
1110 lines
47 KiB
Python
1110 lines
47 KiB
Python
"""
|
|
Generates a self-contained HTML order form from the latest scraped menu.
|
|
|
|
The form shows this week's meals with quantity selectors, a running total,
|
|
and submits orders to the backend (local Flask or cloud API Gateway).
|
|
|
|
Usage:
|
|
python3 generate_form.py # local mode
|
|
python3 generate_form.py --api-url URL --api-key KEY # cloud mode
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
OUTPUT_DIR = PROJECT_ROOT / "output"
|
|
CONFIG_PATH = PROJECT_ROOT / "config.json"
|
|
|
|
|
|
def load_config():
|
|
with open(CONFIG_PATH) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def latest_menu() -> dict:
|
|
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
|
|
if not files:
|
|
print("Error: No menu JSON found. Run scrape_menu.py first.", file=sys.stderr)
|
|
sys.exit(1)
|
|
with open(files[0]) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def generate_form(
|
|
menu: dict,
|
|
config: dict,
|
|
api_url: str = "",
|
|
api_key: str = "",
|
|
bulk_discount: float = 0,
|
|
company_subsidy: float = 0,
|
|
google_client_id: str = "",
|
|
) -> str:
|
|
meals_json = json.dumps(menu["meals"]).replace("</", "<\\/")
|
|
roster_json = json.dumps(config.get("roster", [])).replace("</", "<\\/")
|
|
deadline = config.get("order_deadline", "Thursday 11:59 PM")
|
|
week = datetime.now().strftime("%Y-W%U")
|
|
scraped_at = menu.get("scraped_at", "unknown")
|
|
submit_url = (
|
|
f"{api_url}/api/submit-order" if api_url else "/api/submit-order"
|
|
).replace("</", "<\\/")
|
|
status_url = (
|
|
f"{api_url}/api/form-status/{week}" if api_url else f"/api/form-status/{week}"
|
|
).replace("</", "<\\/")
|
|
roster_url = (f"{api_url}/api/roster" if api_url else "/api/roster").replace(
|
|
"</", "<\\/"
|
|
)
|
|
admin_url = (
|
|
f"{api_url}/api/admin/orders" if api_url else "/api/admin/orders"
|
|
).replace("</", "<\\/")
|
|
admin_pdf_url = (
|
|
f"{api_url}/api/admin/summary-pdf" if api_url else "/api/admin/summary-pdf"
|
|
).replace("</", "<\\/")
|
|
api_key_json = json.dumps(api_key).replace("</", "<\\/")
|
|
has_discount = bulk_discount > 0 or company_subsidy > 0
|
|
use_google_auth = bool(google_client_id)
|
|
google_client_id_json = json.dumps(google_client_id).replace("</", "<\\/")
|
|
|
|
if use_google_auth:
|
|
auth_section_html = """ <div class="employee-info">
|
|
<div id="user-info">
|
|
<div style="display:flex;align-items:center;gap:12px;">
|
|
<img id="user-avatar" style="width:40px;height:40px;border-radius:50%;object-fit:cover;display:none;" alt="">
|
|
<div style="flex:1;">
|
|
<div id="user-name" style="font-weight:600;font-size:0.95rem;"></div>
|
|
<div id="user-email" style="font-size:0.85rem;color:#6b7280;"></div>
|
|
</div>
|
|
<button id="admin-btn" onclick="showAdmin()" style="display:none;background:#1a1a2e;color:#fff;border:none;border-radius:8px;padding:6px 14px;cursor:pointer;font-size:0.8rem;font-weight:600;">Admin</button>
|
|
<button onclick="signOut()" style="background:none;border:1px solid #d1d5db;border-radius:8px;padding:6px 14px;cursor:pointer;font-size:0.8rem;color:#6b7280;">Sign out</button>
|
|
</div>
|
|
</div>
|
|
</div>"""
|
|
else:
|
|
auth_section_html = """ <div class="employee-info">
|
|
<label for="emp-name">Your Name</label>
|
|
<select id="emp-name"><option value="">Loading...</option></select>
|
|
<input type="hidden" id="emp-email">
|
|
</div>"""
|
|
|
|
if use_google_auth:
|
|
google_auth_js = """
|
|
function waitForGoogleAuth() {
|
|
if (typeof google !== 'undefined' && google.accounts && google.accounts.id) {
|
|
initGoogleAuth();
|
|
} else {
|
|
setTimeout(waitForGoogleAuth, 50);
|
|
}
|
|
}
|
|
|
|
function initGoogleAuth() {
|
|
google.accounts.id.initialize({
|
|
client_id: GOOGLE_CLIENT_ID,
|
|
callback: handleCredentialResponse,
|
|
auto_select: true,
|
|
});
|
|
google.accounts.id.renderButton(
|
|
document.getElementById('g-signin-btn'),
|
|
{ theme: 'outline', size: 'large', text: 'signin_with', width: 300 }
|
|
);
|
|
}
|
|
|
|
function handleCredentialResponse(response) {
|
|
googleCredential = response.credential;
|
|
const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
const payloadBytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
|
|
googleUser = { name: payload.name, email: payload.email };
|
|
|
|
document.getElementById('auth-overlay').style.display = 'none';
|
|
document.getElementById('app').style.display = 'block';
|
|
var footer = document.getElementById('sticky-footer');
|
|
if (footer) footer.style.display = 'block';
|
|
document.getElementById('user-name').textContent = payload.name;
|
|
document.getElementById('user-email').textContent = payload.email;
|
|
if (payload.picture) {
|
|
const avatar = document.getElementById('user-avatar');
|
|
avatar.src = payload.picture;
|
|
avatar.style.display = 'block';
|
|
}
|
|
|
|
updateTotal();
|
|
checkDuplicateOrder();
|
|
checkAdmin();
|
|
}
|
|
|
|
function signOut() {
|
|
isAdmin = false;
|
|
var ab = document.getElementById('admin-btn');
|
|
if (ab) ab.style.display = 'none';
|
|
var banner = document.getElementById('admin-closed-banner');
|
|
if (banner) banner.remove();
|
|
googleCredential = null;
|
|
googleUser = null;
|
|
google.accounts.id.disableAutoSelect();
|
|
document.getElementById('auth-overlay').style.display = 'flex';
|
|
document.getElementById('app').style.display = 'none';
|
|
var footer = document.getElementById('sticky-footer');
|
|
if (footer) footer.style.display = 'none';
|
|
if (formClosed) showClosedOverlay();
|
|
}
|
|
"""
|
|
else:
|
|
google_auth_js = ""
|
|
|
|
if use_google_auth:
|
|
submit_order_js = """
|
|
async function submitOrder() {
|
|
if (formClosed && !isAdmin) { alert('Orders are closed.'); return; }
|
|
if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; }
|
|
|
|
const items = Object.entries(quantities).map(([i, qty]) => ({
|
|
name: MEALS[i].name,
|
|
retail_price: MEALS[i].price,
|
|
quantity: qty,
|
|
}));
|
|
|
|
const btn = document.getElementById('submit-btn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Submitting...';
|
|
|
|
try {
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (API_KEY) headers['x-api-key'] = API_KEY;
|
|
const res = await fetch(SUBMIT_URL, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ google_id_token: googleCredential, items }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; }
|
|
document.getElementById('success').style.display = 'block';
|
|
document.getElementById('success-detail').textContent = `${googleUser.name} — $${(data.total || 0).toFixed(2)} total. You're all set!`;
|
|
document.querySelector('.sticky-footer').style.display = 'none';
|
|
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
|
|
} else {
|
|
alert(data.error || 'Something went wrong.');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Submit Order';
|
|
}
|
|
} catch (e) {
|
|
alert('Failed to submit. Check your connection and try again.');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Submit Order';
|
|
}
|
|
}
|
|
"""
|
|
else:
|
|
submit_order_js = """
|
|
async function submitOrder() {
|
|
if (formClosed && !isAdmin) { alert('Orders are closed.'); return; }
|
|
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 items = Object.entries(quantities).map(([i, qty]) => ({
|
|
name: MEALS[i].name,
|
|
retail_price: MEALS[i].price,
|
|
quantity: qty,
|
|
}));
|
|
|
|
const btn = document.getElementById('submit-btn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Submitting...';
|
|
|
|
try {
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (API_KEY) headers['x-api-key'] = API_KEY;
|
|
const res = await fetch(SUBMIT_URL, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ employee_name: name, employee_email: email, items }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; }
|
|
document.getElementById('success').style.display = 'block';
|
|
document.getElementById('success-detail').textContent = `${name} — $${(data.total || 0).toFixed(2)} total. You're all set!`;
|
|
document.querySelector('.sticky-footer').style.display = 'none';
|
|
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
|
|
} else {
|
|
alert(data.error || 'Something went wrong.');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Submit Order';
|
|
}
|
|
} catch (e) {
|
|
alert('Failed to submit. Check your connection and try again.');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Submit Order';
|
|
}
|
|
}
|
|
"""
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<title>Sea Haven — Meal Order ({week})</title>
|
|
<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABX0lEQVRYhe3TPWsUURjF8d+dnSxhoxA1xEJnQQsJGbBTrIIIvoEDNqkNiJ9BrPwI4jeY2kmVoKWkSVALK6MgwWLsJIWIBBFZLGaLTTLrJrX3X96X5znnPPcSiUQikf+dcHAhr+qLuIrT2MV77Owv9ycWy6u6i3tnOu5maTg/FUzjOz5gHe/KImsXkFf1ZTzH9ZbaNdbwElvby/0fwzsJFnANN3uJO/00zJ5MxmrcwqOyyD7uE5BX9Q08xh5O4BL+ZXkXvzQp9RKcS4OzaUukh9nDg7LIVkcFGI04r2pDZw+xgrlx1U516KdB93DnGhvYxBd8w08M0MXnssgmCx7O9RZuYzEw30sszATpXCeY2R/3AC/wTMu420gnHbgyHeY1sb3FH9wfc28DT8oiezOx63EE4DeeYqllb4BXGsevj+L4IEd4M6ysf6X5HUu4oEniE9bKIts5dtdIJBKJREb4C8ngTD+C9DtvAAAAAElFTkSuQmCC">
|
|
<style>
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; color: #1d1d1f; padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px)); }}
|
|
.container {{ max-width: 1400px; margin: 0 auto; padding: 16px; }}
|
|
#meals-list {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); gap: 12px; }}
|
|
header {{ background: #1a1a2e; color: #fff; padding: 24px 16px; text-align: center; margin-bottom: 24px; border-radius: 12px; }}
|
|
header h1 {{ font-size: 1.5rem; margin-bottom: 4px; }}
|
|
header p {{ font-size: 0.85rem; opacity: 0.8; }}
|
|
.deadline {{ background: #fef3c7; color: #92400e; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; text-align: center; font-weight: 500; }}
|
|
.employee-info {{ background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
|
|
.employee-info label {{ display: block; font-weight: 600; margin-bottom: 4px; font-size: 0.85rem; }}
|
|
.employee-info input, .employee-info select {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 12px; }}
|
|
.filters {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }}
|
|
.filters button {{ padding: 6px 14px; border: 1px solid #d1d5db; border-radius: 20px; background: #fff; font-size: 0.8rem; cursor: pointer; transition: all 0.2s; }}
|
|
.filters button.active {{ background: #1a1a2e; color: #fff; border-color: #1a1a2e; }}
|
|
.meal-card {{ background: #fff; border-radius: 12px; padding: 12px; margin-bottom: 0; box-shadow: 0 1px 3px rgba(0,0,0,0.08); display: flex; gap: 12px; align-items: center; }}
|
|
.meal-img {{ width: 80px; height: 80px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }}
|
|
.meal-info {{ flex: 1; min-width: 0; }}
|
|
.meal-name {{ font-weight: 600; font-size: 0.95rem; margin-bottom: 2px; }}
|
|
.meal-meta {{ font-size: 0.8rem; color: #6b7280; margin-bottom: 4px; }}
|
|
.meal-tags {{ display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }}
|
|
.meal-tags span {{ font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: #e0f2fe; color: #0369a1; font-weight: 500; }}
|
|
.meal-tags span.new {{ background: #dcfce7; color: #15803d; }}
|
|
.qty-control {{ display: flex; align-items: center; gap: 0; flex-shrink: 0; }}
|
|
.qty-control button {{ width: 32px; height: 32px; border: 1px solid #d1d5db; background: #f9fafb; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }}
|
|
.qty-control button:first-child {{ border-radius: 8px 0 0 8px; }}
|
|
.qty-control button:last-child {{ border-radius: 0 8px 8px 0; }}
|
|
.qty-control .qty {{ width: 36px; height: 32px; text-align: center; border: 1px solid #d1d5db; border-left: 0; border-right: 0; font-size: 0.95rem; font-weight: 600; }}
|
|
.meal-card.selected {{ border: 2px solid #1a1a2e; }}
|
|
.sticky-footer {{ position: fixed; bottom: 0; left: 0; right: 0; background: #fff; border-top: 1px solid #e5e7eb; padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px)); box-shadow: 0 -2px 8px rgba(0,0,0,0.1); z-index: 100; }}
|
|
.sticky-footer .inner {{ max-width: 1400px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; }}
|
|
.total {{ font-size: 1.1rem; font-weight: 700; }}
|
|
.submit-btn {{ background: #1a1a2e; color: #fff; border: none; padding: 12px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }}
|
|
.submit-btn:disabled {{ opacity: 0.4; cursor: not-allowed; }}
|
|
.submit-btn:hover:not(:disabled) {{ opacity: 0.85; }}
|
|
.success-msg {{ text-align: center; padding: 60px 20px; }}
|
|
.success-msg h2 {{ color: #15803d; margin-bottom: 8px; }}
|
|
.back-btn {{ background: transparent; color: #1a1a2e; border: 2px solid #1a1a2e; padding: 12px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 20px; }}
|
|
.back-btn:hover {{ background: #1a1a2e; color: #fff; }}
|
|
.duplicate-warning {{ background: #fef3c7; color: #92400e; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; text-align: center; font-weight: 500; border: 1px solid #fcd34d; }}
|
|
.meal-desc {{ font-size: 0.8rem; color: #6b7280; margin-bottom: 4px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }}
|
|
.search-bar {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 16px; }}
|
|
.closed-banner {{ background: #fee2e2; color: #991b1b; padding: 16px; border-radius: 8px; margin-bottom: 20px; text-align: center; font-weight: 600; font-size: 1rem; }}
|
|
@media (max-width: 480px) {{
|
|
.meal-card {{ display: grid; grid-template-columns: 80px minmax(0, 1fr); align-items: start; }}
|
|
.qty-control {{ grid-column: 1 / -1; justify-content: center; }}
|
|
}}
|
|
@media (pointer: coarse) {{
|
|
.qty-control button {{ width: 44px; height: 44px; }}
|
|
.qty-control .qty {{ width: 44px; height: 44px; }}
|
|
.edit-qty button {{ width: 44px; height: 44px; }}
|
|
.edit-qty span {{ width: 44px; height: 44px; line-height: 44px; }}
|
|
}}
|
|
.discount-banner {{ background: #dcfce7; color: #15803d; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.85rem; text-align: center; font-weight: 500; }}
|
|
.price-retail {{ text-decoration: line-through; color: #9ca3af; font-size: 0.75rem; margin-right: 4px; }}
|
|
.price-employee {{ color: #15803d; font-weight: 600; }}
|
|
.auth-overlay {{ position: fixed; inset: 0; background: #1a1a2e; display: flex; align-items: center; justify-content: center; z-index: 1000; }}
|
|
.auth-card {{ background: #fff; border-radius: 16px; padding: 48px 40px; text-align: center; max-width: 400px; width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }}
|
|
.auth-card h1 {{ font-size: 1.5rem; margin-bottom: 4px; color: #1d1d1f; }}
|
|
.auth-card p {{ font-size: 0.9rem; color: #6b7280; margin-bottom: 32px; }}
|
|
.auth-card .logo {{ font-size: 2rem; margin-bottom: 16px; }}
|
|
.closed-overlay {{ position: fixed; inset: 0; background: #1a1a2e; display: none; align-items: center; justify-content: center; z-index: 2000; }}
|
|
.closed-card {{ background: #fff; border-radius: 16px; padding: 48px 40px; text-align: center; max-width: 420px; width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }}
|
|
.closed-card h1 {{ font-size: 1.5rem; margin-bottom: 8px; color: #1d1d1f; }}
|
|
.closed-card p {{ font-size: 0.9rem; color: #6b7280; margin-bottom: 24px; }}
|
|
.closed-card .logo {{ font-size: 2.5rem; margin-bottom: 16px; }}
|
|
.countdown {{ font-size: 2rem; font-weight: 700; color: #1a1a2e; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }}
|
|
.countdown-label {{ font-size: 0.75rem; color: #9ca3af; margin-top: 4px; }}
|
|
.admin-panel {{ background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
|
|
.admin-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }}
|
|
.admin-header h2 {{ font-size: 1.2rem; font-weight: 700; margin: 0; }}
|
|
.admin-toolbar {{ display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }}
|
|
.admin-toolbar select {{ padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 0.9rem; }}
|
|
.admin-toolbar-btn {{ margin: 0; padding: 8px 16px; font-size: 0.85rem; background: #1a1a2e; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; }}
|
|
.admin-toolbar-btn:disabled {{ opacity: 0.4; cursor: not-allowed; }}
|
|
.admin-toolbar .back-btn {{ margin: 0; padding: 8px 16px; font-size: 0.85rem; }}
|
|
@media (max-width: 600px) {{
|
|
.admin-toolbar {{ width: 100%; }}
|
|
.admin-toolbar > * {{ flex: 1 1 100%; }}
|
|
}}
|
|
.admin-stats {{ font-size: 0.9rem; color: #6b7280; margin-bottom: 16px; }}
|
|
.admin-stats strong {{ color: #1d1d1f; }}
|
|
.admin-table-wrap {{ overflow-x: auto; }}
|
|
.admin-table {{ width: 100%; border-collapse: collapse; font-size: 0.9rem; }}
|
|
.admin-table th {{ text-align: left; padding: 10px 12px; border-bottom: 2px solid #e5e7eb; font-weight: 600; color: #6b7280; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; white-space: nowrap; }}
|
|
.admin-table td {{ padding: 10px 12px; border-bottom: 1px solid #f3f4f6; vertical-align: top; }}
|
|
.admin-table tr:last-child td {{ border-bottom: none; }}
|
|
.admin-table .items-cell {{ font-size: 0.82rem; color: #4b5563; }}
|
|
.admin-table .total-cell {{ font-weight: 600; white-space: nowrap; }}
|
|
.admin-table .time-cell {{ font-size: 0.8rem; color: #9ca3af; white-space: nowrap; }}
|
|
.admin-total-row {{ background: #f9fafb; font-weight: 700; }}
|
|
.admin-total-row td {{ border-top: 2px solid #e5e7eb; padding: 12px; }}
|
|
.admin-empty {{ text-align: center; padding: 40px 20px; color: #9ca3af; }}
|
|
.admin-actions {{ display: flex; gap: 6px; white-space: nowrap; }}
|
|
.admin-actions button {{ padding: 4px 10px; border-radius: 6px; font-size: 0.78rem; cursor: pointer; font-weight: 500; border: 1px solid; }}
|
|
.btn-edit {{ background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }}
|
|
.btn-edit:hover {{ background: #dbeafe; }}
|
|
.btn-delete {{ background: #fef2f2; color: #dc2626; border-color: #fecaca; }}
|
|
.btn-delete:hover {{ background: #fee2e2; }}
|
|
.btn-save {{ background: #dcfce7; color: #15803d; border-color: #bbf7d0; }}
|
|
.btn-save:hover {{ background: #bbf7d0; }}
|
|
.btn-cancel {{ background: #f9fafb; color: #6b7280; border-color: #d1d5db; }}
|
|
.btn-cancel:hover {{ background: #f3f4f6; }}
|
|
.edit-qty {{ display: inline-flex; align-items: center; gap: 0; margin: 2px 0; }}
|
|
.edit-qty button {{ width: 24px; height: 24px; border: 1px solid #d1d5db; background: #f9fafb; font-size: 0.9rem; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; }}
|
|
.edit-qty button:first-child {{ border-radius: 4px 0 0 4px; }}
|
|
.edit-qty button:last-child {{ border-radius: 0 4px 4px 0; }}
|
|
.edit-qty span {{ width: 28px; height: 24px; text-align: center; line-height: 24px; border: 1px solid #d1d5db; border-left: 0; border-right: 0; font-size: 0.82rem; font-weight: 600; }}
|
|
.edit-qty.removed {{ opacity: 0.4; text-decoration: line-through; }}
|
|
.admin-add-item {{ margin-top: 8px; }}
|
|
.admin-add-item select {{ padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.8rem; max-width: 220px; }}
|
|
</style>
|
|
{
|
|
'<script src="https://accounts.google.com/gsi/client" async defer></script>'
|
|
if use_google_auth
|
|
else ""
|
|
}
|
|
</head>
|
|
<body>
|
|
<div class="closed-overlay" id="closed-overlay">
|
|
<div class="closed-card">
|
|
<div class="logo">🍽</div>
|
|
<h1>Orders Are Closed</h1>
|
|
<p>Orders open again Monday at 8:00 AM ET</p>
|
|
<div class="countdown" id="countdown"></div>
|
|
<div class="countdown-label">until orders open</div>
|
|
</div>
|
|
</div>
|
|
{
|
|
'<div class="auth-overlay" id="auth-overlay"><div class="auth-card"><div class="logo">🍽</div><h1>Sea Haven Meal Order</h1><p>Sign in with your company Google account to place your order.</p><div id="g-signin-btn" style="display:flex;justify-content:center;"></div></div></div>'
|
|
if use_google_auth
|
|
else ""
|
|
}
|
|
<div class="container" id="app" {'style="display:none;"' if use_google_auth else ""}>
|
|
<header>
|
|
<h1>Sea Haven Meal Order</h1>
|
|
<p>Week of {week} · Menu scraped {scraped_at[:10]}</p>
|
|
</header>
|
|
|
|
<div class="deadline">Order deadline: {deadline}</div>
|
|
<div class="duplicate-warning" id="duplicate-warning" style="display:none;">You've already submitted an order this week. Submitting again will replace your previous order.</div>
|
|
{
|
|
'<div class="discount-banner">Prices reflect employee cost after '
|
|
+ (f"{bulk_discount:g}% bulk discount" if bulk_discount > 0 else "")
|
|
+ (" + " if bulk_discount > 0 and company_subsidy > 0 else "")
|
|
+ (f"{company_subsidy:g}% company subsidy" if company_subsidy > 0 else "")
|
|
+ "</div>"
|
|
if has_discount
|
|
else ""
|
|
}
|
|
|
|
{auth_section_html}
|
|
|
|
<input type="text" class="search-bar" id="search" placeholder="Search meals...">
|
|
|
|
<div class="filters" id="filters"></div>
|
|
|
|
<div id="meals-list"></div>
|
|
|
|
<div class="sticky-footer" {
|
|
'style="display:none;" id="sticky-footer"' if use_google_auth else ""
|
|
}>
|
|
<div class="inner">
|
|
<div>
|
|
<span class="total" id="total-display">$0.00</span>
|
|
<span style="font-size:0.8rem;color:#6b7280;margin-left:8px;" id="count-display">0 meals</span>
|
|
</div>
|
|
<button class="submit-btn" id="submit-btn" disabled>Submit Order</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="admin-panel" style="display:none;">
|
|
<div class="admin-panel">
|
|
<div class="admin-header">
|
|
<h2>Order Admin</h2>
|
|
<div class="admin-toolbar">
|
|
<select id="admin-week" onchange="loadWeekOrders(this.value)"></select>
|
|
<button id="admin-download-btn" class="admin-toolbar-btn" onclick="downloadOrderList()" disabled>Download order list</button>
|
|
<button id="admin-pdf-btn" class="admin-toolbar-btn" onclick="downloadSummaryPdf()">Download summary PDF</button>
|
|
<button onclick="hideAdmin()" class="back-btn">Back to Menu</button>
|
|
</div>
|
|
</div>
|
|
<div id="admin-stats" class="admin-stats"></div>
|
|
<div class="admin-table-wrap">
|
|
<table class="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Employee</th>
|
|
<th>Items</th>
|
|
<th>Total</th>
|
|
<th>Submitted</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="admin-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div id="admin-empty" class="admin-empty" style="display:none;">No orders for this week.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="success" class="success-msg" style="display:none;">
|
|
<h2>Order submitted!</h2>
|
|
<p id="success-detail"></p>
|
|
<button class="back-btn" onclick="location.reload()">Back to Menu</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const MEALS = {meals_json};
|
|
const ROSTER = {roster_json};
|
|
const SUBMIT_URL = '{submit_url}';
|
|
const STATUS_URL = '{status_url}';
|
|
const ROSTER_URL = '{roster_url}';
|
|
const ADMIN_URL = '{admin_url}';
|
|
const ADMIN_PDF_URL = '{admin_pdf_url}';
|
|
const API_KEY = {api_key_json};
|
|
const WEEK = '{week}';
|
|
const BULK_DISCOUNT = {bulk_discount};
|
|
const COMPANY_SUBSIDY = {company_subsidy};
|
|
const quantities = {{}};
|
|
let formClosed = false;
|
|
let formStatusData = null;
|
|
let isAdmin = false;
|
|
let adminWeeks = [];
|
|
{
|
|
"const GOOGLE_CLIENT_ID = "
|
|
+ google_client_id_json
|
|
+ ";\nlet googleCredential = null;\nlet googleUser = null;"
|
|
if use_google_auth
|
|
else ""
|
|
}
|
|
|
|
function escapeHtml(str) {{
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}}
|
|
|
|
function employeePrice(retail) {{
|
|
if (!retail) return 0;
|
|
const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100;
|
|
const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100;
|
|
return empPrice;
|
|
}}
|
|
{google_auth_js}
|
|
function checkDuplicateOrder() {{
|
|
try {{
|
|
if (localStorage.getItem('lastOrderWeek') === WEEK) {{
|
|
document.getElementById('duplicate-warning').style.display = 'block';
|
|
}}
|
|
}} catch (e) {{}}
|
|
}}
|
|
|
|
function init() {{
|
|
checkFormStatus();
|
|
{
|
|
"waitForGoogleAuth();"
|
|
if use_google_auth
|
|
else "loadRoster(); checkDuplicateOrder();"
|
|
}
|
|
// Build filter buttons
|
|
const tags = new Set();
|
|
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t)));
|
|
const cats = ['All Meals', ...Array.from(tags).sort()];
|
|
const filtersEl = document.getElementById('filters');
|
|
cats.forEach(cat => {{
|
|
const btn = document.createElement('button');
|
|
btn.textContent = cat;
|
|
if (cat === 'All Meals') btn.classList.add('active');
|
|
btn.onclick = () => {{
|
|
filtersEl.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
renderMeals();
|
|
}};
|
|
filtersEl.appendChild(btn);
|
|
}});
|
|
|
|
document.getElementById('search').addEventListener('input', renderMeals);
|
|
document.getElementById('submit-btn').addEventListener('click', submitOrder);
|
|
|
|
renderMeals();
|
|
}}
|
|
|
|
function renderMeals() {{
|
|
const activeFilter = document.querySelector('.filters button.active')?.textContent || 'All Meals';
|
|
const search = document.getElementById('search').value.toLowerCase();
|
|
const list = document.getElementById('meals-list');
|
|
list.innerHTML = '';
|
|
|
|
MEALS.forEach((meal, i) => {{
|
|
if (activeFilter !== 'All Meals' && !(meal.dietary_tags || []).includes(activeFilter)) return;
|
|
if (search && !meal.name.toLowerCase().includes(search)) return;
|
|
|
|
const qty = quantities[i] || 0;
|
|
const card = document.createElement('div');
|
|
card.className = 'meal-card' + (qty > 0 ? ' selected' : '');
|
|
|
|
let tagsHtml = '';
|
|
if (meal.is_new) tagsHtml += '<span class="new">NEW</span>';
|
|
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{escapeHtml(t)}}</span>`; }});
|
|
|
|
const descHtml = meal.description ? `<div class="meal-desc">${{escapeHtml(meal.description)}}</div>` : '';
|
|
const hasDiscount = (BULK_DISCOUNT > 0 || COMPANY_SUBSIDY > 0) && meal.price;
|
|
const empPrice = employeePrice(meal.price);
|
|
const priceHtml = hasDiscount
|
|
? `<span class="price-retail">$${{meal.price.toFixed(2)}}</span><span class="price-employee">$${{empPrice.toFixed(2)}}</span>`
|
|
: (meal.price ? `$${{meal.price.toFixed(2)}}` : '—');
|
|
|
|
const safeName = escapeHtml(meal.name);
|
|
const safeImageUrl = escapeHtml(meal.image_url);
|
|
card.innerHTML = `
|
|
${{meal.image_url ? `<img class="meal-img" src="${{safeImageUrl}}" alt="${{safeName}}" loading="lazy">` : ''}}
|
|
<div class="meal-info">
|
|
<div class="meal-name">${{safeName}}</div>
|
|
${{descHtml}}
|
|
<div class="meal-meta">${{priceHtml}} · ${{escapeHtml(meal.calories || '?')}} cal · ${{escapeHtml(meal.protein || '?')}} protein</div>
|
|
<div class="meal-tags">${{tagsHtml}}</div>
|
|
</div>
|
|
<div class="qty-control">
|
|
<button onclick="changeQty(${{i}}, -1)" ${{formClosed && !isAdmin ? 'disabled' : ''}}>−</button>
|
|
<input class="qty" type="text" value="${{qty}}" readonly>
|
|
<button onclick="changeQty(${{i}}, 1)" ${{formClosed && !isAdmin ? 'disabled' : ''}}>+</button>
|
|
</div>
|
|
`;
|
|
list.appendChild(card);
|
|
}});
|
|
|
|
updateTotal();
|
|
}}
|
|
|
|
function changeQty(index, delta) {{
|
|
const current = quantities[index] || 0;
|
|
const next = Math.max(0, current + delta);
|
|
if (next === 0) delete quantities[index]; else quantities[index] = next;
|
|
renderMeals();
|
|
}}
|
|
|
|
function updateTotal() {{
|
|
let total = 0, count = 0;
|
|
Object.entries(quantities).forEach(([i, qty]) => {{
|
|
total += employeePrice(MEALS[i].price || 0) * qty;
|
|
count += qty;
|
|
}});
|
|
document.getElementById('total-display').textContent = `$${{total.toFixed(2)}}`;
|
|
document.getElementById('count-display').textContent = `${{count}} meal${{count !== 1 ? 's' : ''}}`;
|
|
document.getElementById('submit-btn').disabled = count === 0{
|
|
" || !googleCredential" if use_google_auth else ""
|
|
};
|
|
}}
|
|
|
|
{submit_order_js}
|
|
|
|
{
|
|
""
|
|
if use_google_auth
|
|
else '''async function loadRoster() {
|
|
try {
|
|
const res = await fetch(ROSTER_URL);
|
|
const data = await res.json();
|
|
const select = document.getElementById('emp-name');
|
|
select.innerHTML = '<option value="">Select your name</option>';
|
|
(data.employees || ROSTER).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 || '';
|
|
});
|
|
} catch (e) {
|
|
const select = document.getElementById('emp-name');
|
|
select.innerHTML = '<option value="">Select your name</option>';
|
|
ROSTER.forEach(emp => {
|
|
const opt = document.createElement('option');
|
|
opt.value = emp.name;
|
|
opt.dataset.email = emp.email;
|
|
opt.textContent = emp.name;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
}
|
|
'''
|
|
}
|
|
function showClosedOverlay() {{
|
|
document.getElementById('closed-overlay').style.display = 'flex';
|
|
document.querySelectorAll('.qty-control button').forEach(b => b.disabled = true);
|
|
var submitBtn = document.getElementById('submit-btn');
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
if (formStatusData) startCountdown(formStatusData);
|
|
}}
|
|
|
|
function adminBypassClosed() {{
|
|
document.getElementById('closed-overlay').style.display = 'none';
|
|
document.querySelectorAll('.qty-control button').forEach(b => b.disabled = false);
|
|
var submitBtn = document.getElementById('submit-btn');
|
|
if (submitBtn) submitBtn.disabled = Object.keys(quantities).length === 0;
|
|
var existing = document.getElementById('admin-closed-banner');
|
|
if (!existing) {{
|
|
var banner = document.createElement('div');
|
|
banner.id = 'admin-closed-banner';
|
|
banner.className = 'closed-banner';
|
|
banner.textContent = 'Form is closed — admin override active';
|
|
var deadline = document.querySelector('.deadline');
|
|
if (deadline) deadline.parentNode.insertBefore(banner, deadline.nextSibling);
|
|
}}
|
|
renderMeals();
|
|
}}
|
|
|
|
function checkAdmin() {{
|
|
if (!ADMIN_URL) return;
|
|
const headers = {{}};
|
|
if (typeof googleCredential !== 'undefined' && googleCredential) {{
|
|
headers['Authorization'] = 'Bearer ' + googleCredential;
|
|
}}
|
|
fetch(ADMIN_URL, {{ headers }})
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(data => {{
|
|
if (data && data.weeks) {{
|
|
isAdmin = true;
|
|
adminWeeks = data.weeks;
|
|
var ab = document.getElementById('admin-btn');
|
|
if (ab) ab.style.display = 'inline-block';
|
|
if (formClosed) adminBypassClosed();
|
|
}} else if (formClosed) {{
|
|
showClosedOverlay();
|
|
}}
|
|
}})
|
|
.catch(() => {{
|
|
if (formClosed) showClosedOverlay();
|
|
}});
|
|
}}
|
|
|
|
function showAdmin() {{
|
|
document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => {{
|
|
if (el.id !== 'admin-panel') el.dataset.prevDisplay = el.style.display || '';
|
|
el.style.display = 'none';
|
|
}});
|
|
const panel = document.getElementById('admin-panel');
|
|
panel.style.display = 'block';
|
|
var footer = document.querySelector('.sticky-footer');
|
|
if (footer) footer.style.display = 'none';
|
|
|
|
const select = document.getElementById('admin-week');
|
|
select.innerHTML = '';
|
|
adminWeeks.forEach(w => {{
|
|
const opt = document.createElement('option');
|
|
opt.value = w.week;
|
|
opt.textContent = w.week + (w.form_status === 'open' ? ' (open)' : '');
|
|
select.appendChild(opt);
|
|
}});
|
|
if (adminWeeks.length > 0) loadWeekOrders(adminWeeks[0].week);
|
|
}}
|
|
|
|
function hideAdmin() {{
|
|
document.getElementById('admin-panel').style.display = 'none';
|
|
document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => {{
|
|
el.style.display = el.dataset.prevDisplay || '';
|
|
delete el.dataset.prevDisplay;
|
|
}});
|
|
var footer = document.querySelector('.sticky-footer');
|
|
if (footer) footer.style.display = '';
|
|
}}
|
|
|
|
let currentAdminWeek = '';
|
|
let editingOrder = null;
|
|
let editQuantities = {{}};
|
|
|
|
function adminHeaders() {{
|
|
const h = {{}};
|
|
if (typeof googleCredential !== 'undefined' && googleCredential) h['Authorization'] = 'Bearer ' + googleCredential;
|
|
return h;
|
|
}}
|
|
|
|
function loadWeekOrders(week) {{
|
|
if (!week) return;
|
|
currentAdminWeek = week;
|
|
editingOrder = null;
|
|
const tbody = document.getElementById('admin-tbody');
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:20px;color:#9ca3af;">Loading...</td></tr>';
|
|
document.getElementById('admin-empty').style.display = 'none';
|
|
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" style="text-align:center;padding:20px;color:#991b1b;">Failed to load orders.</td></tr>';
|
|
}});
|
|
}}
|
|
|
|
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').style.display = 'block';
|
|
document.getElementById('admin-stats').textContent = '';
|
|
return;
|
|
}}
|
|
document.getElementById('admin-empty').style.display = 'none';
|
|
document.getElementById('admin-stats').innerHTML =
|
|
'<strong>' + data.total_employees + '</strong> employee' + (data.total_employees !== 1 ? 's' : '') +
|
|
' ordered · <strong>$' + data.grand_total.toFixed(2) + '</strong> total payroll deductions';
|
|
|
|
data.orders.forEach((order, idx) => {{
|
|
const tr = document.createElement('tr');
|
|
tr.id = 'admin-row-' + idx;
|
|
const isEditing = editingOrder === order.employee_email;
|
|
|
|
let itemsHtml, actionsHtml, totalHtml;
|
|
if (isEditing) {{
|
|
const editNames = Object.keys(editQuantities);
|
|
itemsHtml = editNames.map(name => {{
|
|
const q = editQuantities[name] || 0;
|
|
const eName = escapeHtml(name).replace(/'/g, "\\\\'");
|
|
const cls = q === 0 ? 'edit-qty removed' : 'edit-qty';
|
|
return '<div class="' + cls + '">' +
|
|
'<button onclick="adminEditQty(\\'' + eName + '\\',-1)">−</button>' +
|
|
'<span>' + q + '</span>' +
|
|
'<button onclick="adminEditQty(\\'' + eName + '\\',1)">+</button>' +
|
|
'</div> ' + escapeHtml(name);
|
|
}}).join('<br>');
|
|
const availableMeals = MEALS.filter(m => !editQuantities.hasOwnProperty(m.name));
|
|
if (availableMeals.length > 0) {{
|
|
itemsHtml += '<div class="admin-add-item"><select onchange="adminAddItem(this.value); this.selectedIndex=0;">' +
|
|
'<option value="">+ Add item...</option>' +
|
|
availableMeals.map(m => '<option value="' + escapeHtml(m.name).replace(/"/g, '"') + '">' + escapeHtml(m.name) + ' ($' + m.price.toFixed(2) + ')</option>').join('') +
|
|
'</select></div>';
|
|
}}
|
|
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);
|
|
actionsHtml = '<div class="admin-actions"><button class="btn-save" onclick="adminSaveEdit(\\'' + escapeHtml(order.employee_email) + '\\')">Save</button><button class="btn-cancel" onclick="adminCancelEdit()">Cancel</button></div>';
|
|
}} else {{
|
|
itemsHtml = order.items.map(i =>
|
|
escapeHtml(i.name) + ' ×' + i.quantity + ' <span style="color:#9ca3af;">($' + i.subtotal.toFixed(2) + ')</span>'
|
|
).join('<br>');
|
|
totalHtml = '$' + order.total.toFixed(2);
|
|
actionsHtml = '<div class="admin-actions"><button class="btn-edit" onclick="adminStartEdit(' + idx + ',\\'' + escapeHtml(order.employee_email) + '\\')">Edit</button><button class="btn-delete" onclick="adminDeleteOrder(\\'' + escapeHtml(order.employee_email) + '\\',\\'' + escapeHtml(order.employee_name) + '\\')">Delete</button></div>';
|
|
}}
|
|
|
|
const submitted = order.submitted_at ? new Date(order.submitted_at).toLocaleString('en-US', {{
|
|
timeZone: 'America/New_York', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
|
|
}}) : '';
|
|
tr.innerHTML =
|
|
'<td><strong>' + escapeHtml(order.employee_name) + '</strong><br><span style="font-size:0.8rem;color:#9ca3af;">' + escapeHtml(order.employee_email) + '</span></td>' +
|
|
'<td class="items-cell">' + itemsHtml + '</td>' +
|
|
'<td class="total-cell">' + totalHtml + '</td>' +
|
|
'<td class="time-cell">' + submitted + '</td>' +
|
|
'<td>' + actionsHtml + '</td>';
|
|
tbody.appendChild(tr);
|
|
}});
|
|
const totalRow = document.createElement('tr');
|
|
totalRow.className = 'admin-total-row';
|
|
totalRow.innerHTML = '<td>Total</td><td>' + data.orders.reduce((s,o) => s + o.items.reduce((a,i) => a + i.quantity, 0), 0) + ' meals</td><td class="total-cell">$' + data.grand_total.toFixed(2) + '</td><td></td><td></td>';
|
|
tbody.appendChild(totalRow);
|
|
}}
|
|
|
|
// Aggregate the loaded week's orders into item -> total quantity (no employee
|
|
// breakdown, no prices) and download as CSV to assist with bulk ordering.
|
|
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);
|
|
}}
|
|
|
|
// Fetch a short-lived presigned URL for the loaded week's summary PDF
|
|
// (generated at Thursday close) and open it. 404 means the week has not
|
|
// closed yet, so no PDF exists.
|
|
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) {{
|
|
window.location.href = data.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; }});
|
|
}}
|
|
|
|
let lastAdminData = null;
|
|
const origLoadWeekOrders = loadWeekOrders;
|
|
|
|
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));
|
|
}}
|
|
|
|
async function checkFormStatus() {{
|
|
if (!STATUS_URL) return;
|
|
try {{
|
|
const res = await fetch(STATUS_URL);
|
|
const data = await res.json();
|
|
if (data.status === 'closed') {{
|
|
formClosed = true;
|
|
formStatusData = data;
|
|
if (isAdmin) {{
|
|
adminBypassClosed();
|
|
}} else if (typeof GOOGLE_CLIENT_ID !== 'undefined' && GOOGLE_CLIENT_ID) {{
|
|
// Defer overlay until after Google auth resolves — checkAdmin() will
|
|
// show the overlay for non-admins or bypass it for admins.
|
|
}} else {{
|
|
showClosedOverlay();
|
|
}}
|
|
}}
|
|
}} catch (e) {{}}
|
|
}}
|
|
|
|
let reopenAtMs = null;
|
|
|
|
const easternFormatter = new Intl.DateTimeFormat('en-US', {{
|
|
timeZone: 'America/New_York',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hourCycle: 'h23',
|
|
}});
|
|
|
|
function getEasternParts(date) {{
|
|
const values = {{}};
|
|
easternFormatter.formatToParts(date).forEach(part => {{
|
|
if (part.type !== 'literal') values[part.type] = Number(part.value);
|
|
}});
|
|
return values;
|
|
}}
|
|
|
|
function getEasternOffsetMs(date) {{
|
|
const parts = getEasternParts(date);
|
|
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - date.getTime();
|
|
}}
|
|
|
|
function startCountdown(data) {{
|
|
if (data && data.reopen_at) {{
|
|
reopenAtMs = data.reopen_at * 1000;
|
|
}}
|
|
updateCountdown();
|
|
setInterval(updateCountdown, 1000);
|
|
}}
|
|
|
|
function updateCountdown() {{
|
|
const now = new Date();
|
|
let target;
|
|
|
|
if (reopenAtMs) {{
|
|
target = new Date(reopenAtMs);
|
|
}} else {{
|
|
const etNow = getEasternParts(now);
|
|
const day = new Date(Date.UTC(etNow.year, etNow.month - 1, etNow.day)).getUTCDay();
|
|
const hour = etNow.hour;
|
|
|
|
let daysUntil = (1 - day + 7) % 7;
|
|
if (daysUntil === 0 && hour >= 8) daysUntil = 7;
|
|
|
|
const targetEtMs = Date.UTC(etNow.year, etNow.month - 1, etNow.day + daysUntil, 8, 0, 0);
|
|
target = new Date(targetEtMs - getEasternOffsetMs(new Date(targetEtMs)));
|
|
}}
|
|
|
|
let diff = Math.max(0, Math.floor((target - now) / 1000));
|
|
const d = Math.floor(diff / 86400); diff %= 86400;
|
|
const h = Math.floor(diff / 3600); diff %= 3600;
|
|
const m = Math.floor(diff / 60);
|
|
const s = diff % 60;
|
|
|
|
const pad = n => String(n).padStart(2, '0');
|
|
document.getElementById('countdown').textContent =
|
|
`${{d}}d ${{pad(h)}}h ${{pad(m)}}m ${{pad(s)}}s`;
|
|
}}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Generate meal order form HTML")
|
|
parser.add_argument(
|
|
"--api-url", default="", help="API Gateway base URL (cloud mode)"
|
|
)
|
|
parser.add_argument(
|
|
"--api-key", default="", help="API key for order submission (cloud mode)"
|
|
)
|
|
parser.add_argument(
|
|
"--bulk-discount",
|
|
type=float,
|
|
default=None,
|
|
help="Bulk discount percentage (e.g., 10 for 10%% off)",
|
|
)
|
|
parser.add_argument(
|
|
"--company-subsidy",
|
|
type=float,
|
|
default=None,
|
|
help="Company subsidy percentage (e.g., 50 for 50%% off after bulk discount)",
|
|
)
|
|
parser.add_argument(
|
|
"--google-client-id",
|
|
default=None,
|
|
help="Google OAuth Client ID for Sign-In authentication",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
config = load_config()
|
|
menu = latest_menu()
|
|
|
|
bulk_discount = (
|
|
args.bulk_discount
|
|
if args.bulk_discount is not None
|
|
else config.get("bulk_discount_percent", 0)
|
|
)
|
|
company_subsidy = (
|
|
args.company_subsidy
|
|
if args.company_subsidy is not None
|
|
else config.get("company_subsidy_percent", 0)
|
|
)
|
|
google_client_id = args.google_client_id or config.get("google_client_id", "")
|
|
if not google_client_id:
|
|
try:
|
|
import boto3
|
|
|
|
ssm = boto3.client("ssm")
|
|
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
|
|
google_client_id = resp["Parameter"]["Value"]
|
|
except Exception:
|
|
pass
|
|
|
|
html = generate_form(
|
|
menu,
|
|
config,
|
|
api_url=args.api_url,
|
|
api_key=args.api_key,
|
|
bulk_discount=bulk_discount,
|
|
company_subsidy=company_subsidy,
|
|
google_client_id=google_client_id,
|
|
)
|
|
|
|
week = datetime.now().strftime("%Y-W%U")
|
|
output_file = OUTPUT_DIR / f"order-form-{week}.html"
|
|
with open(output_file, "w") as f:
|
|
f.write(html)
|
|
|
|
mode = "cloud" if args.api_url else "local"
|
|
print(f"Generated order form ({mode} mode): {output_file}")
|
|
print(f" {menu['meal_count']} meals from menu scraped {menu['scraped_at'][:10]}")
|
|
if mode == "local":
|
|
print(" Start the server with: python3 src/server/app.py")
|
|
else:
|
|
print(f" API endpoint: {args.api_url}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|