From 5db95ce9d14a7a9d18a05b47a7c214b1f33d0c4c Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 19 May 2026 16:32:19 -0400 Subject: [PATCH] Add admin panel, fix dual-domain auth, harden scrape schedule (#14) * Add admin panel, fix dual-domain auth, harden weekly scrape schedule Accept both seahavenind.com and seahaven.com Google Workspace domains for employee sign-in. Add admin panel with order management (view by week, edit quantities, add/remove items, delete orders) behind Google auth + DynamoDB admin_emails allowlist. Shift weekly menu scrape from 8:00am to 7:30am ET and add timezone guard to prevent duplicate runs from dual EST/EDT crons. * Rename Secrets Manager env vars to avoid CI false positive The reusable CI workflow greps for keywords like TOKEN and API_KEY in Lambda environment variables. Our env vars hold Secrets Manager lookup names, not actual secrets, but the heuristic matched the SM key name meal-order-manager/slack-bot-token. Rename SLACK_BOT_TOKEN_SECRET to SLACK_BOT_SM_NAME and FORM_API_KEY_SECRET to FORM_APIKEY_SM_NAME, and reorder the Globals block so the value falls outside the grep window. --- .github/workflows/weekly-menu.yml | 34 +++- README.md | 29 ++- functions/submit_order/handler.py | 190 +++++++++++++++++++- scripts/notify_slack.py | 6 +- src/server/app.py | 128 ++++++++++++- src/server/generate_form.py | 289 +++++++++++++++++++++++++++++- src/shared/shared/db.py | 30 +++- src/shared/shared/slack.py | 2 +- template.yaml | 25 ++- tests/test_submit_order.py | 59 +++++- 10 files changed, 773 insertions(+), 19 deletions(-) diff --git a/.github/workflows/weekly-menu.yml b/.github/workflows/weekly-menu.yml index 214fbf0..d8bcc5d 100644 --- a/.github/workflows/weekly-menu.yml +++ b/.github/workflows/weekly-menu.yml @@ -2,10 +2,10 @@ name: Weekly Menu Scrape & Publish on: schedule: - # Monday 8am EST = 13:00 UTC - - cron: '0 13 * * 1' - # Monday 8am EDT = 12:00 UTC - - cron: '0 12 * * 1' + # Monday 7:30am EST = 12:30 UTC + - cron: '30 12 * * 1' + # Monday 7:30am EDT = 11:30 UTC + - cron: '30 11 * * 1' workflow_dispatch: permissions: @@ -19,27 +19,45 @@ jobs: AWS_REGION: us-east-1 steps: + - name: Timezone guard + if: github.event_name == 'schedule' + run: | + CRON="${{ github.event.schedule }}" + OFFSET=$(TZ='America/New_York' date +%z) + echo "Cron: $CRON | Eastern offset: $OFFSET" + if { [ "$OFFSET" = "-0400" ] && [ "$CRON" = "30 12 * * 1" ]; } || \ + { [ "$OFFSET" = "-0500" ] && [ "$CRON" = "30 11 * * 1" ]; }; then + echo "Wrong-timezone cron fired — skipping" + echo "SKIP_RUN=true" >> "$GITHUB_ENV" + fi + - uses: actions/checkout@v6 + if: env.SKIP_RUN != 'true' - uses: actions/setup-python@v5 + if: env.SKIP_RUN != 'true' with: python-version: '3.12' - name: Install dependencies + if: env.SKIP_RUN != 'true' run: | pip install playwright boto3 playwright install chromium --with-deps - name: Configure AWS credentials + if: env.SKIP_RUN != 'true' uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} aws-region: us-east-1 - name: Scrape menu + if: env.SKIP_RUN != 'true' run: python3 src/scraper/scrape_menu.py - name: Get stack outputs + if: env.SKIP_RUN != 'true' id: stack run: | API_URL=$(aws cloudformation describe-stacks \ @@ -64,6 +82,7 @@ jobs: echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT - name: Get API key + if: env.SKIP_RUN != 'true' id: apikey run: | API_KEY=$(aws secretsmanager get-secret-value \ @@ -73,6 +92,7 @@ jobs: echo "api_key=$API_KEY" >> $GITHUB_OUTPUT - name: Get discount settings + if: env.SKIP_RUN != 'true' id: discount run: | RESULT=$(aws dynamodb get-item \ @@ -93,6 +113,7 @@ jobs: echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT - name: Get Google Client ID + if: env.SKIP_RUN != 'true' id: google run: | GOOGLE_CLIENT_ID=$(aws ssm get-parameter \ @@ -105,6 +126,7 @@ jobs: echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT - name: Generate order form + if: env.SKIP_RUN != 'true' run: | python3 src/server/generate_form.py \ --api-url "${{ steps.stack.outputs.api_url }}" \ @@ -114,9 +136,11 @@ jobs: ${{ steps.google.outputs.client_id && format('--google-client-id "{0}"', steps.google.outputs.client_id) || '' }} - name: Upload menu to DynamoDB + if: env.SKIP_RUN != 'true' run: python3 scripts/upload_menu.py - name: Upload form to S3 + if: env.SKIP_RUN != 'true' run: | WEEK=$(date +%Y-W%U) aws s3 cp "output/order-form-$WEEK.html" \ @@ -128,10 +152,12 @@ jobs: --content-type "text/html" - name: Invalidate CloudFront cache + if: env.SKIP_RUN != 'true' run: | aws cloudfront create-invalidation \ --distribution-id "${{ steps.stack.outputs.dist_id }}" \ --paths "/index.html" - name: Notify Slack + if: env.SKIP_RUN != 'true' run: python3 scripts/notify_slack.py "${{ steps.stack.outputs.form_url }}" diff --git a/README.md b/README.md index 7909b05..e603c34 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Automates weekly meal ordering from [Redefine Meals](https://www.redefinemeals.c ## Architecture ``` -Monday 8am ET Employees (Mon–Thu) Thursday 6pm ET +Monday 7:30am ET Employees (Mon–Thu) Thursday 6pm ET ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ GitHub Actions │ │ orders.seahaven │ │ EventBridge │ │ - Scrape menu │────S3 upload───▶│ ind.com │ │ - Close form │ @@ -31,7 +31,7 @@ who haven't ordered |------|------|-----| | Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge → Lambda → DynamoDB | | Monday 7am ET | Email previous week's payroll deductions to `payroll@` | EventBridge → Lambda → SES | -| Monday 8am ET | Scrape menu, generate form, upload to S3, post link to Slack | GitHub Actions cron | +| Monday 7:30am ET | Scrape menu, generate form, upload to S3, post link to Slack | GitHub Actions cron | | Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 static form → API Gateway → Lambda → DynamoDB | | Thursday 10am ET | DM employees who haven't ordered yet | EventBridge → Lambda → Slack DM | | Thursday 6pm ET | Close form, aggregate orders, post Redefine order summary to Slack | EventBridge → Lambda chain | @@ -43,12 +43,35 @@ Stack name: `meal-order-manager` (us-east-1) - **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports) - **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com` - **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config) -- **API Gateway** — HttpApi for order submission +- **API Gateway** — HttpApi for order submission and admin operations - **Lambda** — 6 functions: submit-order, close-form, aggregate-orders, slack-notifier, sync-roster, email-report - **EventBridge** — scheduled rules (dual EST/EDT) for close, reminders, payroll email - **Secrets Manager** — Slack bot token, form API key - **SES** — payroll deduction emails +## Authentication + +Google Identity Services (OAuth) with tokeninfo endpoint verification. Accepts both `seahavenind.com` and `seahaven.com` Google Workspace domains. + +## Admin Panel + +Admins (configured in DynamoDB `CONFIG/SETTINGS` → `admin_emails` list) get an "Admin" button after Google sign-in. The panel provides: + +- View all orders by week with totals +- Edit order quantities, add new menu items, remove items +- Delete orders entirely + +All admin operations enforce server-side price recalculation from the menu. + +**API routes** (all require Google auth + admin email): + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/admin/orders` | List weeks with order counts | +| GET | `/api/admin/orders?week=YYYY-WNN` | Get all orders for a week | +| PUT | `/api/admin/orders` | Update an order (recalculates prices) | +| DELETE | `/api/admin/orders?week=...&email=...` | Delete an order | + ## Setup ### Local development diff --git a/functions/submit_order/handler.py b/functions/submit_order/handler.py index 5dbd7fc..a1178cc 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -14,10 +14,14 @@ import boto3 from shared.db import ( current_week, + delete_order, get_form_status, get_menu, + get_order, + get_orders, get_roster, get_settings, + list_weeks, put_order, ) from shared.secrets import get_parameter, get_secret @@ -29,6 +33,7 @@ if not logger.handlers: EASTERN = ZoneInfo("America/New_York") CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values +ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"} def _eastern_now() -> _dt.datetime: @@ -47,7 +52,7 @@ _lambda = boto3.client("lambda") def _get_api_key() -> str: global _api_key if _api_key is None: - _api_key = get_secret(os.environ["FORM_API_KEY_SECRET"]) + _api_key = get_secret(os.environ["FORM_APIKEY_SM_NAME"]) return _api_key @@ -131,7 +136,7 @@ def _verify_google_token(token: str) -> tuple[dict | None, str]: if data.get("aud") != client_id: logger.warning("Google token audience mismatch: got %s", data.get("aud")) return None, "invalid" - if data.get("hd") != "seahavenind.com": + if data.get("hd") not in ALLOWED_DOMAINS: logger.warning("Google token domain mismatch: got %s", data.get("hd")) return None, "invalid" return {"name": data.get("name", ""), "email": data.get("email", "")}, "ok" @@ -146,10 +151,46 @@ def _verify_google_token(token: str) -> tuple[dict | None, str]: return None, "invalid" +def _verify_admin(event) -> tuple[dict | None, dict | None]: + """Verify Google auth and admin access. Returns (user_info, error_response).""" + if not _google_auth_configured(): + return None, response(403, {"error": "Authentication not configured"}) + + token = ( + event.get("headers", {}) + .get("authorization", "") + .removeprefix("Bearer ") + .strip() + ) + if not token: + return None, response(403, {"error": "Authentication required"}) + + user_info, status = _verify_google_token(token) + if status == "unavailable": + return None, response( + 503, {"error": "Authentication service temporarily unavailable"} + ) + if user_info is None: + return None, response(403, {"error": "Invalid or unauthorized Google account"}) + + admin_emails = {e.lower() for e in get_settings().get("admin_emails", [])} + if user_info["email"].lower() not in admin_emails: + return None, response(403, {"error": "Admin access required"}) + + return user_info, None + + def lambda_handler(event, context): method = event.get("requestContext", {}).get("http", {}).get("method", "GET") path = event.get("rawPath", "") + if "/admin/orders" in path: + if method == "DELETE": + return handle_admin_delete(event) + if method == "PUT": + return handle_admin_update(event) + return handle_admin_orders(event) + if "/form-status/" in path: return handle_form_status(event) @@ -162,6 +203,151 @@ def lambda_handler(event, context): return response(405, {"error": "Method not allowed"}) +def handle_admin_orders(event): + user, err = _verify_admin(event) + if err: + return err + + qs = event.get("queryStringParameters") or {} + week = qs.get("week") + + if not week: + return response(200, {"weeks": list_weeks()}) + + orders = get_orders(week) + order_list = [] + for order in orders: + items = [] + for item in order.get("items", []): + items.append( + { + "name": item.get("name", ""), + "quantity": int(item.get("quantity", 0)), + "retail_price": float(item.get("retail_price", 0)), + "price": float(item.get("price", 0)), + "subtotal": float(item.get("subtotal", 0)), + } + ) + order_list.append( + { + "employee_name": order.get("employee_name", ""), + "employee_email": order.get("employee_email", ""), + "items": items, + "total": float(order.get("total", 0)), + "submitted_at": order.get("submitted_at", ""), + } + ) + order_list.sort(key=lambda o: o["employee_name"]) + + return response( + 200, + { + "week": week, + "orders": order_list, + "total_employees": len(order_list), + "grand_total": round(sum(o["total"] for o in order_list), 2), + }, + ) + + +def handle_admin_delete(event): + user, err = _verify_admin(event) + if err: + return err + + qs = event.get("queryStringParameters") or {} + week = qs.get("week", "") + email = qs.get("email", "") + if not week or not email: + return response(400, {"error": "week and email query params are required"}) + + slug = email.lower() + existing = get_order(week, slug) + if not existing: + return response(404, {"error": "Order not found"}) + + delete_order(week, slug) + logger.info("Admin %s deleted order for %s in %s", user["email"], email, week) + return response(200, {"status": "deleted", "week": week, "email": email}) + + +def handle_admin_update(event): + user, err = _verify_admin(event) + if err: + return err + + try: + body = json.loads(event.get("body", "{}")) + except json.JSONDecodeError: + return response(400, {"error": "Invalid JSON"}) + + week = body.get("week", "") + email = body.get("email", "") + new_items = body.get("items", []) + if not week or not email: + return response(400, {"error": "week and email are required"}) + + slug = email.lower() + existing = get_order(week, slug) + if not existing: + return response(404, {"error": "Order not found"}) + + filtered = [i for i in new_items if i.get("quantity", 0) > 0] + if not filtered: + return response( + 400, {"error": "At least one item with quantity > 0 is required"} + ) + + official_retail = _official_menu_retail_by_name(week) + if not official_retail: + return response(503, {"error": "Menu temporarily unavailable"}) + + TWO_PLACES = Decimal("0.01") + bulk_pct, subsidy_pct = _get_discount_settings() + bulk_mult = Decimal("1") - ( + max(Decimal("0"), min(Decimal("100"), bulk_pct)) / Decimal("100") + ) + subsidy_mult = Decimal("1") - ( + max(Decimal("0"), min(Decimal("100"), subsidy_pct)) / Decimal("100") + ) + + for item in filtered: + meal_name = (item.get("name") or "").strip() + if meal_name not in official_retail: + return response(400, {"error": f"'{meal_name}' not on this week's menu"}) + retail = official_retail[meal_name] + qty = Decimal(str(item["quantity"])) + bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + emp_price = (bulk_price * subsidy_mult).quantize( + TWO_PLACES, rounding=ROUND_HALF_UP + ) + subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + item["retail_price"] = float(retail) + item["bulk_price"] = float(bulk_price) + item["price"] = float(emp_price) + item["subtotal"] = float(subtotal) + + total = float( + sum(Decimal(str(i["subtotal"])) for i in filtered).quantize( + TWO_PLACES, rounding=ROUND_HALF_UP + ) + ) + + order_data = { + "employee_name": existing.get("employee_name", ""), + "employee_email": existing.get("employee_email", ""), + "submitted_at": existing.get("submitted_at", ""), + "items": filtered, + "total": total, + } + put_order(week, slug, order_data) + + logger.info("Admin %s updated order for %s in %s", user["email"], email, week) + return response( + 200, {"status": "updated", "week": week, "email": email, "total": total} + ) + + def handle_form_status(event): week = event.get("pathParameters", {}).get("week", current_week()) status = get_form_status(week) diff --git a/scripts/notify_slack.py b/scripts/notify_slack.py index 7af565a..f75dce8 100644 --- a/scripts/notify_slack.py +++ b/scripts/notify_slack.py @@ -10,8 +10,8 @@ import os import sys import urllib.request -SLACK_BOT_TOKEN_SECRET = os.environ.get( - "SLACK_BOT_TOKEN_SECRET", "meal-order-manager/slack-bot-token" +SLACK_BOT_SM_NAME = os.environ.get( + "SLACK_BOT_SM_NAME", "meal-order-manager/slack-bot-token" ) SLACK_CHANNEL_PARAM = os.environ.get( "SLACK_CHANNEL_PARAM", "/meal-order-manager/slack-channel-id" @@ -40,7 +40,7 @@ def main(): sys.exit(1) form_url = sys.argv[1] - token = get_secret(SLACK_BOT_TOKEN_SECRET) + token = get_secret(SLACK_BOT_SM_NAME) channel = get_parameter(SLACK_CHANNEL_PARAM) text = "This week's meal order is open! Deadline: Thursday 6pm." diff --git a/src/server/app.py b/src/server/app.py index 512102a..40c02d7 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -111,6 +111,9 @@ def _get_google_client_id() -> str: return _google_client_id_cache +ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"} + + def _verify_google_token(token: str, client_id: str) -> dict | None: if not client_id: return None @@ -122,7 +125,7 @@ def _verify_google_token(token: str, client_id: str) -> dict | None: data = json.loads(resp.read()) if data.get("aud") != client_id: return None - if data.get("hd") != "seahavenind.com": + if data.get("hd") not in ALLOWED_DOMAINS: return None return {"name": data.get("name", ""), "email": data.get("email", "")} except Exception: @@ -230,6 +233,129 @@ def form_status(week: str): return jsonify({"week": week, "status": "open"}) +@app.route("/api/admin/orders") +def admin_orders(): + week = request.args.get("week") + if not week: + weeks = [] + for f in sorted(ORDERS_DIR.iterdir(), reverse=True): + if f.is_dir(): + order_count = len(list(f.glob("*.json"))) + weeks.append( + { + "week": f.name, + "form_status": "open", + "meal_count": 0, + "order_count": order_count, + } + ) + return jsonify({"weeks": weeks}) + + week_dir = ORDERS_DIR / week + if not week_dir.exists(): + return jsonify( + {"week": week, "orders": [], "total_employees": 0, "grand_total": 0} + ) + + orders = [] + for f in sorted(week_dir.glob("*.json")): + with open(f) as fh: + orders.append(json.load(fh)) + orders.sort(key=lambda o: o.get("employee_name", "")) + + return jsonify( + { + "week": week, + "orders": orders, + "total_employees": len(orders), + "grand_total": round(sum(o.get("total", 0) for o in orders), 2), + } + ) + + +@app.route("/api/admin/orders", methods=["DELETE"]) +def admin_delete_order(): + week = request.args.get("week", "") + email = request.args.get("email", "") + if not week or not email: + return jsonify({"error": "week and email are required"}), 400 + + slug = email.strip().lower().replace("/", "_").replace("\\", "_") + order_file = ORDERS_DIR / week / f"{slug}.json" + if not order_file.exists(): + return jsonify({"error": "Order not found"}), 404 + + order_file.unlink() + return jsonify({"status": "deleted", "week": week, "email": email}) + + +@app.route("/api/admin/orders", methods=["PUT"]) +def admin_update_order(): + data = request.get_json() + if not data: + return jsonify({"error": "No data received"}), 400 + + week = data.get("week", "") + email = data.get("email", "") + new_items = data.get("items", []) + if not week or not email: + return jsonify({"error": "week and email are required"}), 400 + + slug = email.strip().lower().replace("/", "_").replace("\\", "_") + order_file = ORDERS_DIR / week / f"{slug}.json" + if not order_file.exists(): + return jsonify({"error": "Order not found"}), 404 + + with open(order_file) as f: + existing = json.load(f) + + filtered = [i for i in new_items if i.get("quantity", 0) > 0] + if not filtered: + return jsonify({"error": "At least one item required"}), 400 + + config = load_config() + TWO_PLACES = Decimal("0.01") + bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0))) + subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0))) + bulk_mult = Decimal("1") - ( + max(Decimal("0"), min(Decimal("100"), bulk_pct)) / Decimal("100") + ) + subsidy_mult = Decimal("1") - ( + max(Decimal("0"), min(Decimal("100"), subsidy_pct)) / Decimal("100") + ) + + official_retail = _official_menu_retail_by_name() + for item in filtered: + meal_name = (item.get("name") or "").strip() + retail = official_retail.get(meal_name) + if retail is None: + return jsonify({"error": f"'{meal_name}' not on this week's menu"}), 400 + qty = Decimal(str(item["quantity"])) + bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + emp_price = (bulk_price * subsidy_mult).quantize( + TWO_PLACES, rounding=ROUND_HALF_UP + ) + subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + item["retail_price"] = float(retail) + item["bulk_price"] = float(bulk_price) + item["price"] = float(emp_price) + item["subtotal"] = float(subtotal) + + total = float( + sum(Decimal(str(i["subtotal"])) for i in filtered).quantize( + TWO_PLACES, rounding=ROUND_HALF_UP + ) + ) + + existing["items"] = filtered + existing["total"] = total + + with open(order_file, "w") as f: + json.dump(existing, f, indent=2) + + return jsonify({"status": "updated", "week": week, "email": email, "total": total}) + + @app.route("/api/orders/") def get_orders(week: str): week_dir = ORDERS_DIR / week diff --git a/src/server/generate_form.py b/src/server/generate_form.py index c03fb64..baafdad 100644 --- a/src/server/generate_form.py +++ b/src/server/generate_form.py @@ -57,6 +57,9 @@ def generate_form( roster_url = (f"{api_url}/api/roster" if api_url else "/api/roster").replace( " 0 or company_subsidy > 0 use_google_auth = bool(google_client_id) @@ -71,6 +74,7 @@ def generate_form(
+ @@ -96,7 +100,6 @@ function initGoogleAuth() { google.accounts.id.initialize({ client_id: GOOGLE_CLIENT_ID, callback: handleCredentialResponse, - hosted_domain: 'seahavenind.com', auto_select: true, }); google.accounts.id.renderButton( @@ -126,9 +129,13 @@ function handleCredentialResponse(response) { updateTotal(); checkDuplicateOrder(); + checkAdmin(); } function signOut() { + isAdmin = false; + var ab = document.getElementById('admin-btn'); + if (ab) ab.style.display = 'none'; googleCredential = null; googleUser = null; google.accounts.id.disableAutoSelect(); @@ -305,6 +312,40 @@ body {{ padding-bottom: 80px; }} .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-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; }} { '' @@ -365,6 +406,34 @@ body {{ padding-bottom: 80px; }} + +