commit 8ba8c95ecd8e228e9a8d97fbb9cee2a63c2a68ff Author: bonamin Date: Wed May 20 14:04:38 2026 +0300 feat: initial commit — local services (backend + manager dashboard + waiter PWA) Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..baccc3a --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Registry +REGISTRY=registry.yourdomain.com +VERSION=1.0.0 + +# Backend runtime secrets (get SITE_ID and SITE_KEY from sysadmin panel) +SITE_ID=your-site-id +SITE_KEY=your-site-key +CLOUD_URL=https://api.yourdomain.com +SECRET_KEY=generate-with-openssl-rand-hex-32 +LICENSE_GRACE_HOURS=24 + +# Volumes — absolute paths recommended on client machines +DATA_PATH=/home/user/appdata/pos/data +LOGO_PATH=/home/user/appdata/pos/logo.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4acf482 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Environment +.env +*.env + +# Database +*.db +*.db-shm +*.db-wal + +# License state (runtime file) +license_state.json + +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ +venv/ +env/ + +# Node (for future frontends) +node_modules/ +dist/ +.next/ + +# OS +.DS_Store +Thumbs.db + +# Fiscal driver tests & documentation +FISCAL-DRIVER-TESTS/ + +# Runtime data (databases, uploaded images) +data/ + +# SSL certificates (generated per-machine with mkcert — never commit) +certs/*.pem +certs/*.crt +certs/*.key diff --git a/Readme b/Readme new file mode 100644 index 0000000..e69de29 diff --git a/certs/.gitkeep b/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..8f46efd --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,20 @@ +services: + backend: + build: ./local_backend + image: ${REGISTRY}/pos-backend:${VERSION:-latest} + ports: + - "8000:8000" + + waiter_pwa: + build: + context: ./waiter_pwa + image: ${REGISTRY}/pos-waiter:${VERSION:-latest} + ports: + - "5173:80" + + manager_dashboard: + build: + context: ./manager_dashboard + image: ${REGISTRY}/pos-manager:${VERSION:-latest} + ports: + - "5174:80" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e4ffcc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + backend: + image: ${REGISTRY}/pos-backend:${VERSION:-latest} + restart: unless-stopped + environment: + - SITE_ID=${SITE_ID} + - SITE_KEY=${SITE_KEY} + - CLOUD_URL=${CLOUD_URL} + - SECRET_KEY=${SECRET_KEY} + - LICENSE_GRACE_HOURS=${LICENSE_GRACE_HOURS:-24} + - DATABASE_URL=sqlite:////app/data/pos.db + - VERSION=${VERSION:-0.0.0} + volumes: + - ${DATA_PATH}:/app/data + - ${LOGO_PATH}:/app/logo.png:ro + + waiter_pwa: + image: ${REGISTRY}/pos-waiter:${VERSION:-latest} + restart: unless-stopped + depends_on: + - backend + + manager_dashboard: + image: ${REGISTRY}/pos-manager:${VERSION:-latest} + restart: unless-stopped + depends_on: + - backend + + proxy: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + - "4443:4443" + volumes: + - ./nginx-proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./certs:/etc/nginx/certs:ro + depends_on: + - waiter_pwa + - manager_dashboard + restart: unless-stopped diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..ef6850b --- /dev/null +++ b/install.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Xenia POS — first-time install script +# Run this on the server machine before starting the stack. +# Usage: bash install.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "=== Xenia POS Install ===" +echo "" + +# ── 1. Create required directories ─────────────────────────────────────────── +echo "[ 1/4 ] Creating directories..." +mkdir -p "$SCRIPT_DIR/data" +mkdir -p "$SCRIPT_DIR/certs" +mkdir -p "$SCRIPT_DIR/nginx-proxy" + +# ── 2. Write nginx-proxy/nginx.conf ────────────────────────────────────────── +echo "[ 2/4 ] Writing nginx proxy config..." +cat > "$SCRIPT_DIR/nginx-proxy/nginx.conf" << 'EOF' +server { + listen 80; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name _; + + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://waiter_pwa:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 4443 ssl; + server_name _; + + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://manager_dashboard:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +EOF + +# ── 3. SSL certificates ─────────────────────────────────────────────────────── +echo "[ 3/4 ] Setting up SSL certificates..." + +if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then + echo " Certificates already exist — skipping." +else + echo "" + echo " No certificates found in certs/" + echo " You need a cert for your domain (e.g. xeniapos.yourdomain.com)." + echo "" + echo " Option A — Let's Encrypt (recommended for production):" + echo " sudo apt install certbot" + echo " sudo certbot certonly --manual --preferred-challenges dns -d YOUR_DOMAIN" + echo " sudo cp /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem certs/cert.pem" + echo " sudo cp /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem certs/key.pem" + echo "" + echo " Option B — Self-signed (local testing only, requires CA install on each device):" + echo " sudo apt install mkcert libnss3-tools" + echo " mkcert -install" + echo " mkcert -cert-file certs/cert.pem -key-file certs/key.pem YOUR_IP localhost" + echo "" + echo " Add certs then re-run this script, or run: docker compose up -d" + echo "" +fi + +# ── 4. Create placeholder logo if missing ──────────────────────────────────── +echo "[ 4/4 ] Checking logo..." +if [ ! -f "$SCRIPT_DIR/logo.png" ]; then + echo " WARNING: logo.png not found." + echo " Place your logo at: $SCRIPT_DIR/logo.png" + echo " Creating placeholder so the stack can start..." + touch "$SCRIPT_DIR/logo.png" +fi + +# ── Done ───────────────────────────────────────────────────────────────────── +echo "" +echo "=== Setup complete ===" +echo "" + +if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then + echo "Starting stack..." + docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d + echo "" + echo "Done! Services are running." +else + echo "Add SSL certificates to certs/ then run:" + echo " docker compose up -d" +fi diff --git a/local_backend/.dockerignore b/local_backend/.dockerignore new file mode 100644 index 0000000..9b0fe69 --- /dev/null +++ b/local_backend/.dockerignore @@ -0,0 +1,7 @@ +pos.db +license_state.json +__pycache__ +*.pyc +*.pyo +.env +data/ diff --git a/local_backend/Dockerfile b/local_backend/Dockerfile new file mode 100644 index 0000000..617e0d7 --- /dev/null +++ b/local_backend/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/local_backend/config.py b/local_backend/config.py new file mode 100644 index 0000000..4f30ee7 --- /dev/null +++ b/local_backend/config.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path +from pydantic_settings import BaseSettings + +_HERE = Path(__file__).parent # always points to local_backend/ + +# Use AppData on Windows (avoids Controlled Folder Access blocks), +# fall back to local_backend/ on Linux/Mac/Docker +if os.name == "nt": + _DB_DEFAULT = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "pos" / "pos.db" + _DB_DEFAULT.parent.mkdir(parents=True, exist_ok=True) +else: + _DB_DEFAULT = _HERE / "pos.db" + + +class Settings(BaseSettings): + SITE_ID: str = "" + SITE_KEY: str = "" + CLOUD_URL: str = "" + SECRET_KEY: str = "change-me-generate-a-long-random-string" + DATABASE_URL: str = f"sqlite:///{_DB_DEFAULT.as_posix()}" + VERSION: str = "0.0.0" + + model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"} + + +settings = Settings() diff --git a/local_backend/database.py b/local_backend/database.py new file mode 100644 index 0000000..a26623d --- /dev/null +++ b/local_backend/database.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker +from config import settings + +engine = create_engine( + settings.DATABASE_URL, + connect_args={"check_same_thread": False}, # needed for SQLite +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/local_backend/main.py b/local_backend/main.py new file mode 100644 index 0000000..4fb9332 --- /dev/null +++ b/local_backend/main.py @@ -0,0 +1,277 @@ +import os +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from database import engine, Base +from middleware.license_check import LicenseCheckMiddleware +from services.cloud_sync import start_cloud_sync + +# Import all models so SQLAlchemy can create their tables +import models.user # noqa: F401 — also registers WaiterZone +import models.table # noqa: F401 +import models.printer # noqa: F401 +import models.product # noqa: F401 +import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount +import models.business_day # noqa: F401 +import models.shift # noqa: F401 — registers WaiterShift, ShiftBreak +import models.settings # noqa: F401 +import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment +import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate + +from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router +from routers import business_day as business_day_router +from routers import shifts as shifts_router +from routers import settings as settings_router +from routers import flags as flags_router +from routers import messages as messages_router +from routers import sse as sse_router +from routers import data_transfer as data_transfer_router + + +def _run_migrations(): + """Apply additive schema changes that create_all won't handle. + Each migration gets its own connection so a no-op (column already exists) + doesn't leave a dirty transaction that blocks subsequent migrations.""" + from sqlalchemy import text + + migrations = [ + "ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0", + "ALTER TABLE products ADD COLUMN image_url VARCHAR", + "ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)", + "ALTER TABLE table_groups ADD COLUMN prefix VARCHAR", + "ALTER TABLE table_groups ADD COLUMN color VARCHAR", + "ALTER TABLE products ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE product_preference_sets ADD COLUMN default_choice_id INTEGER", + "ALTER TABLE product_preference_choices ADD COLUMN sub_choices TEXT", + "ALTER TABLE product_preference_choices ADD COLUMN disables_subset INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE product_preference_sets ADD COLUMN shared_subset TEXT", + "ALTER TABLE product_options ADD COLUMN sub_choices TEXT", + # Zone-based access control + """CREATE TABLE IF NOT EXISTS waiter_zones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + waiter_id INTEGER NOT NULL REFERENCES users(id), + group_id INTEGER REFERENCES table_groups(id), + assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", + # Payment tracking on items + "ALTER TABLE order_items ADD COLUMN paid_by INTEGER REFERENCES users(id)", + "ALTER TABLE order_items ADD COLUMN paid_at DATETIME", + "ALTER TABLE order_items ADD COLUMN payment_method VARCHAR", + # Full audit log + """CREATE TABLE IF NOT EXISTS order_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id), + event_type VARCHAR NOT NULL, + waiter_id INTEGER REFERENCES users(id), + item_ids TEXT, + amount REAL, + payment_method VARCHAR, + note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", + # Waiter profile fields + "ALTER TABLE users ADD COLUMN full_name VARCHAR", + "ALTER TABLE users ADD COLUMN nickname VARCHAR", + "ALTER TABLE users ADD COLUMN mobile_phone VARCHAR", + "ALTER TABLE users ADD COLUMN avatar_url VARCHAR", + # Quick options (flat, allow_multiple) + """CREATE TABLE IF NOT EXISTS product_quick_options ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL REFERENCES products(id), + name VARCHAR NOT NULL, + price REAL NOT NULL DEFAULT 0.0, + allow_multiple INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0 + )""", + # allow_multiple flag on extras (product_options) + "ALTER TABLE product_options ADD COLUMN allow_multiple INTEGER NOT NULL DEFAULT 0", + # Discounts table (future-proofed, schema ready now) + """CREATE TABLE IF NOT EXISTS order_discounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id), + item_id INTEGER REFERENCES order_items(id), + discount_type VARCHAR NOT NULL, + discount_value REAL NOT NULL, + applied_by INTEGER NOT NULL REFERENCES users(id), + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, + reason TEXT + )""", + # Business day scoping on orders + "ALTER TABLE orders ADD COLUMN business_day_id INTEGER REFERENCES business_days(id)", + # Shift attribution on paid items + "ALTER TABLE order_items ADD COLUMN paid_in_shift_id INTEGER REFERENCES waiter_shifts(id)", + # Seed default POS settings (INSERT OR IGNORE = no-op if already exists) + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_start', 'true', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_end', 'true', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('business_day.force_close_allowed', 'true', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('flags.display_mode', 'both', CURRENT_TIMESTAMP)", + # Table flags + """CREATE TABLE IF NOT EXISTS table_flag_defs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR NOT NULL, + emoji VARCHAR, + color VARCHAR DEFAULT '#6b7280', + text_color VARCHAR DEFAULT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", + # Migration: add text_color if upgrading from older schema + "ALTER TABLE table_flag_defs ADD COLUMN text_color VARCHAR DEFAULT NULL", + """CREATE TABLE IF NOT EXISTS table_flag_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + table_id INTEGER NOT NULL REFERENCES tables(id), + flag_id INTEGER NOT NULL REFERENCES table_flag_defs(id), + assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP, + assigned_by INTEGER REFERENCES users(id) + )""", + # Staff messaging + """CREATE TABLE IF NOT EXISTS quick_message_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body VARCHAR NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", + """CREATE TABLE IF NOT EXISTS staff_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + target_waiter_ids TEXT NOT NULL DEFAULT '[]', + table_ids TEXT NOT NULL DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", + """CREATE TABLE IF NOT EXISTS staff_message_acks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES staff_messages(id), + waiter_id INTEGER NOT NULL REFERENCES users(id), + acked_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", + # Seed default flag definitions + "INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (1, 'Χρειάζεται καθάρισμα', '🧹', '#ef4444', 1)", + "INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (2, 'Χρειάζεται Βοήθεια', '🆘', '#f97316', 2)", + "INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (3, 'Χρειάζεται Σερβιτόρο', '🔔', '#eab308', 3)", + "INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (4, 'Περιμένει να πληρώσει', '💳', '#3b82f6', 4)", + "INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (5, 'VIP', '⭐', '#8b5cf6', 5)", + "INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (6, 'Ευγενικός Πελάτης', '😊', '#22c55e', 6)", + "INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (7, 'Αγενής Πελάτης', '😤', '#dc2626', 7)", + "INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (8, 'Αλλεργίες', '⚠️', '#f59e0b', 8)", + "INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (9, 'Παιδιά στο τραπέζι', '👶', '#06b6d4', 9)", + "INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (10, 'Επέτειος / Γενέθλια', '🎂', '#ec4899', 10)", + # Seed default quick message templates + "INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (1, 'Σε χρειάζομαι τώρα', 1)", + "INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (2, 'Πάρε διάλειμμα', 2)", + "INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (3, 'Ετοιμάσου για κλείσιμο', 3)", + "INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (4, 'Ήρθε νέος πελάτης', 4)", + "INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (5, 'Ο πελάτης περιμένει να πληρώσει', 5)", + # Product lifecycle status (active / archived) + "ALTER TABLE products ADD COLUMN lifecycle_status VARCHAR NOT NULL DEFAULT 'active'", + # Favorite flags + ordering on all product sub-item types + "ALTER TABLE product_quick_options ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE product_quick_options ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE product_options ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE product_options ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE product_ingredients ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE product_ingredients ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE product_preference_sets ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE product_preference_sets ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0", + # Sub-category support + "ALTER TABLE categories ADD COLUMN parent_id INTEGER REFERENCES categories(id)", + "ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0", + # Auto-expand flag for sub-categories on the PWA accordion + "ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0", + # Printer protocol field + "ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'", + # Compact (half-width) display flag for quick options + "ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0", + # Print layout + per-type font settings + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.ticket_mode', 'detailed', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_number', '48:1:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_meta', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_name', '16:1:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_quick', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_pref', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_extra', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_ingredient', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_note', '0:0:0', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_note', '0:1:0', CURRENT_TIMESTAMP)", + # Offline/emergency payment tracking + "ALTER TABLE order_audit_log ADD COLUMN offline_uuid VARCHAR", + "ALTER TABLE order_audit_log ADD COLUMN offline_at VARCHAR", + "ALTER TABLE order_audit_log ADD COLUMN is_duplicate INTEGER NOT NULL DEFAULT 0", + # Cancellation tracking on order items (for reports) + "ALTER TABLE order_items ADD COLUMN cancelled_by INTEGER REFERENCES users(id)", + "ALTER TABLE order_items ADD COLUMN cancel_reason TEXT", + "ALTER TABLE order_items ADD COLUMN cancelled_at DATETIME", + # Manager account fields (added for setup wizard / future password login) + "ALTER TABLE users ADD COLUMN password_hash VARCHAR", + "ALTER TABLE users ADD COLUMN email VARCHAR", + # Venue identity settings + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.name', '', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.type', '', CURRENT_TIMESTAMP)", + # Security settings + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.login_method', 'password', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.autofill_username', 'true', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock', 'false', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock_seconds', '300', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout', 'false', CURRENT_TIMESTAMP)", + "INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout_seconds', '1800', CURRENT_TIMESTAMP)", + ] + for sql in migrations: + try: + with engine.connect() as conn: + conn.execute(text(sql)) + conn.commit() + except Exception: + pass + + +@asynccontextmanager +async def lifespan(app: FastAPI): + import asyncio + from services.sse_bus import init_loop + init_loop(asyncio.get_running_loop()) + Base.metadata.create_all(bind=engine) + _run_migrations() + sync_task = await start_cloud_sync() + yield + sync_task.cancel() + + +app = FastAPI(title="POS Local Backend", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) +app.add_middleware(LicenseCheckMiddleware) + +# Serve product images as static files +IMAGE_DIR = "/app/data/product_images" +os.makedirs(IMAGE_DIR, exist_ok=True) +app.mount("/static/product_images", StaticFiles(directory=IMAGE_DIR), name="product_images") + +# Serve waiter avatars as static files +AVATAR_DIR = "/app/data/avatars" +os.makedirs(AVATAR_DIR, exist_ok=True) +app.mount("/static/avatars", StaticFiles(directory=AVATAR_DIR), name="avatars") + +app.include_router(setup_router.router, prefix="/api/setup", tags=["setup"]) +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(tables.router, prefix="/api/tables", tags=["tables"]) +app.include_router(products.router, prefix="/api/products", tags=["products"]) +app.include_router(orders.router, prefix="/api/orders", tags=["orders"]) +app.include_router(waiters.router, prefix="/api/waiters", tags=["waiters"]) +app.include_router(reports.router, prefix="/api/reports", tags=["reports"]) +app.include_router(system.router, prefix="/api/system", tags=["system"]) +app.include_router(business_day_router.router, prefix="/api/business-day", tags=["business-day"]) +app.include_router(shifts_router.router, prefix="/api/shifts", tags=["shifts"]) +app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) +app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"]) +app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"]) +app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"]) +app.include_router(data_transfer_router.router, prefix="/api/data-transfer", tags=["data-transfer"]) diff --git a/local_backend/middleware/__init__.py b/local_backend/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_backend/middleware/license_check.py b/local_backend/middleware/license_check.py new file mode 100644 index 0000000..d79ee82 --- /dev/null +++ b/local_backend/middleware/license_check.py @@ -0,0 +1,69 @@ +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +# Shared mutable state — updated by cloud_sync.py +# Fields: +# licensed bool — False only after 72h offline OR after expiry grace passes +# locked bool — True once lock_pending is enforced at workday close +# lock_pending bool — Cloud requested lock; waiting for workday to close +# expires_at str|None — ISO timestamp from cloud +# days_until_expiry int|None — negative when expired +# grace_expires_at str|None — ISO timestamp of expiry + 5 days +# last_sync str|None — ISO timestamp of last successful heartbeat +# sync_failed bool +# latest_version str|None +license_state: dict = { + "licensed": True, + "locked": False, + "lock_pending": False, + "expires_at": None, + "days_until_expiry": None, + "grace_expires_at": None, + "last_sync": None, + "sync_failed": False, + "latest_version": None, +} + +# Paths that bypass all license checks (health probe) +EXEMPT_PATHS = {"/api/system/health"} + +# Paths that are always allowed so the frontend can read license status +# and managers can still log in / close the workday when restricted +STATUS_ALLOWED_PATHS = { + "/api/system/status", + "/api/system/sync-license", + "/api/auth/login", + "/api/auth/me", + "/api/business-day/current", + "/api/business-day/close", +} + + +class LicenseCheckMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + path = request.url.path + + if path in EXEMPT_PATHS or path in STATUS_ALLOWED_PATHS: + return await call_next(request) + + # Hard block: licensed=False means either 72h offline grace expired + # OR expiry grace period (5 days) has passed. In both cases the + # business_day router already prevented opening a new workday, so + # existing operations can still complete — we only block new ones. + # The business_day /open endpoint has its own detailed error message. + if not license_state.get("licensed", True): + return Response( + content='{"detail":"license_expired","code":"LICENSE_EXPIRED"}', + status_code=402, + media_type="application/json", + ) + + # Hard block: locked=True (lock_pending was enforced at workday close) + if license_state.get("locked"): + return Response( + content='{"detail":"system_locked","code":"SYSTEM_LOCKED"}', + status_code=423, + media_type="application/json", + ) + + return await call_next(request) diff --git a/local_backend/models/__init__.py b/local_backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_backend/models/business_day.py b/local_backend/models/business_day.py new file mode 100644 index 0000000..393037d --- /dev/null +++ b/local_backend/models/business_day.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from database import Base + + +def _utcnow(): + return datetime.now(timezone.utc) + + +class BusinessDay(Base): + __tablename__ = "business_days" + + id = Column(Integer, primary_key=True, index=True) + status = Column(String, default="open", nullable=False) # 'open' | 'closed' + opened_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False) + opened_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) + closed_at = Column(DateTime(timezone=True), nullable=True) + closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + notes = Column(Text, nullable=True) + + opener = relationship("User", foreign_keys=[opened_by_id]) + closer = relationship("User", foreign_keys=[closed_by_id]) + shifts = relationship("WaiterShift", back_populates="business_day") diff --git a/local_backend/models/flag.py b/local_backend/models/flag.py new file mode 100644 index 0000000..b6914b7 --- /dev/null +++ b/local_backend/models/flag.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from database import Base + + +def _utcnow(): + return datetime.now(timezone.utc) + + +class TableFlagDef(Base): + """Manager-configurable flag definitions (name, emoji, color).""" + __tablename__ = "table_flag_defs" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + emoji = Column(String, nullable=True) + color = Column(String, nullable=True, default="#6b7280") # hex background + text_color = Column(String, nullable=True, default=None) # hex text; None = white + sort_order = Column(Integer, default=0, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), default=_utcnow) + + assignments = relationship("TableFlagAssignment", back_populates="flag_def", cascade="all, delete-orphan") + + +class TableFlagAssignment(Base): + """Active flag on a specific table.""" + __tablename__ = "table_flag_assignments" + + id = Column(Integer, primary_key=True, index=True) + table_id = Column(Integer, ForeignKey("tables.id"), nullable=False) + flag_id = Column(Integer, ForeignKey("table_flag_defs.id"), nullable=False) + assigned_at = Column(DateTime(timezone=True), default=_utcnow) + assigned_by = Column(Integer, ForeignKey("users.id"), nullable=True) + + flag_def = relationship("TableFlagDef", back_populates="assignments") + assigned_by_user = relationship("User") diff --git a/local_backend/models/message.py b/local_backend/models/message.py new file mode 100644 index 0000000..4b593e3 --- /dev/null +++ b/local_backend/models/message.py @@ -0,0 +1,48 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from database import Base + + +def _utcnow(): + return datetime.now(timezone.utc) + + +class QuickMessageTemplate(Base): + """Manager-configurable quick message templates.""" + __tablename__ = "quick_message_templates" + + id = Column(Integer, primary_key=True, index=True) + body = Column(String, nullable=False) + sort_order = Column(Integer, default=0, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), default=_utcnow) + + +class StaffMessage(Base): + """A message sent from a manager to one or more waiters.""" + __tablename__ = "staff_messages" + + id = Column(Integer, primary_key=True, index=True) + sender_id = Column(Integer, ForeignKey("users.id"), nullable=False) + body = Column(Text, nullable=False) + # JSON arrays stored as text: "[1,2,3]" for waiter ids, "[5,6]" for table ids + target_waiter_ids = Column(Text, nullable=False, default="[]") + table_ids = Column(Text, nullable=False, default="[]") + created_at = Column(DateTime(timezone=True), default=_utcnow) + + sender = relationship("User", foreign_keys=[sender_id]) + acks = relationship("StaffMessageAck", back_populates="message", cascade="all, delete-orphan") + + +class StaffMessageAck(Base): + """Acknowledgement by a specific waiter for a specific message.""" + __tablename__ = "staff_message_acks" + + id = Column(Integer, primary_key=True, index=True) + message_id = Column(Integer, ForeignKey("staff_messages.id"), nullable=False) + waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False) + acked_at = Column(DateTime(timezone=True), default=_utcnow) + + message = relationship("StaffMessage", back_populates="acks") + waiter = relationship("User") diff --git a/local_backend/models/order.py b/local_backend/models/order.py new file mode 100644 index 0000000..d214f57 --- /dev/null +++ b/local_backend/models/order.py @@ -0,0 +1,131 @@ +from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from database import Base + + +def _utcnow(): + return datetime.now(timezone.utc) + + +class Order(Base): + __tablename__ = "orders" + + id = Column(Integer, primary_key=True, index=True) + table_id = Column(Integer, ForeignKey("tables.id"), nullable=False) + opened_by = Column(Integer, ForeignKey("users.id"), nullable=False) + opened_at = Column(DateTime(timezone=True), default=_utcnow) + status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled + closed_at = Column(DateTime, nullable=True) + closed_by = Column(Integer, ForeignKey("users.id"), nullable=True) + notes = Column(Text, nullable=True) + business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True) + + table = relationship("Table", back_populates="orders") + opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened") + closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed") + items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") + waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan") + print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan") + audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan") + discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan") + + +class OrderWaiter(Base): + __tablename__ = "order_waiters" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False) + assigned_at = Column(DateTime(timezone=True), default=_utcnow) + + order = relationship("Order", back_populates="waiters") + waiter = relationship("User", back_populates="order_assignments") + + +class OrderItem(Base): + __tablename__ = "order_items" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + added_by = Column(Integer, ForeignKey("users.id"), nullable=False) + quantity = Column(Integer, nullable=False) + unit_price = Column(Float, nullable=False) # price snapshot at time of order + selected_options = Column(Text, nullable=True) # JSON array of option ids + removed_ingredients = Column(Text, nullable=True) # JSON array of ingredient ids + notes = Column(Text, nullable=True) + status = Column(String, default="active", nullable=False) # active|paid|cancelled + added_at = Column(DateTime(timezone=True), default=_utcnow) + printed = Column(Boolean, default=False, nullable=False) + + # Payment tracking + paid_by = Column(Integer, ForeignKey("users.id"), nullable=True) + paid_at = Column(DateTime, nullable=True) + payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use + paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True) + + order = relationship("Order", back_populates="items") + product = relationship("Product", back_populates="order_items") + added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items") + paid_by_user = relationship("User", foreign_keys=[paid_by], back_populates="items_paid") + + +class PrintLog(Base): + __tablename__ = "print_log" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False) + printed_at = Column(DateTime(timezone=True), default=_utcnow) + item_ids = Column(Text, nullable=False) # JSON array of order_item ids + success = Column(Boolean, nullable=False) + error_message = Column(Text, nullable=True) + + order = relationship("Order", back_populates="print_logs") + printer = relationship("Printer", back_populates="print_logs") + + +class OrderAuditLog(Base): + """Immutable append-only audit trail for every action on an order.""" + __tablename__ = "order_audit_log" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + event_type = Column(String, nullable=False) + # ORDER_OPENED | ITEMS_ADDED | PAYMENT | PAYMENT_OFFLINE | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED + waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True) + item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids + amount = Column(Float, nullable=True) # total value for PAYMENT events + payment_method = Column(String, nullable=True) + note = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), default=_utcnow) + # Emergency offline payment fields + offline_uuid = Column(String, nullable=True) # client-generated UUID for dedup + offline_at = Column(String, nullable=True) # ISO timestamp from client + is_duplicate = Column(Integer, nullable=False, default=0) # 1 = duplicate payment flagged + + order = relationship("Order", back_populates="audit_logs") + waiter = relationship("User") + + @property + def waiter_name(self): + return self.waiter.username if self.waiter else None + + +class OrderDiscount(Base): + """Records a discount applied to an order or a specific item.""" + __tablename__ = "order_discounts" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL = whole-order discount + discount_type = Column(String, nullable=False) # 'percent' | 'fixed' + discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00 + applied_by = Column(Integer, ForeignKey("users.id"), nullable=False) + applied_at = Column(DateTime(timezone=True), default=_utcnow) + reason = Column(Text, nullable=True) + + order = relationship("Order", back_populates="discounts") + item = relationship("OrderItem") + applied_by_user = relationship("User") diff --git a/local_backend/models/printer.py b/local_backend/models/printer.py new file mode 100644 index 0000000..8f8de7d --- /dev/null +++ b/local_backend/models/printer.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String, Boolean +from sqlalchemy.orm import relationship +from database import Base + + +class Printer(Base): + __tablename__ = "printers" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + ip_address = Column(String, nullable=False) + port = Column(Integer, default=9100, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + protocol = Column(String, default="escpos_tcp", nullable=False) + + products = relationship("Product", back_populates="printer_zone") + print_logs = relationship("PrintLog", back_populates="printer") diff --git a/local_backend/models/product.py b/local_backend/models/product.py new file mode 100644 index 0000000..29c5463 --- /dev/null +++ b/local_backend/models/product.py @@ -0,0 +1,123 @@ +from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey, Text +from sqlalchemy.orm import relationship +from database import Base + + +class Category(Base): + __tablename__ = "categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + color = Column(String, nullable=True) + sort_order = Column(Integer, default=0) + # self-referential: null = top-level, non-null = sub-category of parent + parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True) + # position of the "General" group (direct products) among sub-categories + general_sort_order = Column(Integer, default=0, nullable=False) + # sub-categories only: if True, the accordion section is expanded by default on the PWA + auto_expanded = Column(Boolean, default=False, nullable=False) + + products = relationship("Product", back_populates="category") + subcategories = relationship("Category", back_populates="parent", foreign_keys="Category.parent_id") + parent = relationship("Category", back_populates="subcategories", remote_side="Category.id", foreign_keys="Category.parent_id") + + +class Product(Base): + __tablename__ = "products" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) + base_price = Column(Float, nullable=False) + is_available = Column(Boolean, default=True, nullable=False) + # "active" | "archived" — archived products are kept for order history but hidden from active use + lifecycle_status = Column(String, default="active", nullable=False) + printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True) + image_url = Column(String, nullable=True) + sort_order = Column(Integer, default=0, nullable=False) + + category = relationship("Category", back_populates="products") + printer_zone = relationship("Printer", back_populates="products") + quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan") + options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan") + ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan") + preference_sets = relationship("ProductPreferenceSet", back_populates="product", cascade="all, delete-orphan") + order_items = relationship("OrderItem", back_populates="product") + + +class ProductOption(Base): + __tablename__ = "product_options" + + id = Column(Integer, primary_key=True, index=True) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + name = Column(String, nullable=False) + extra_cost = Column(Float, default=0.0) + allow_multiple = Column(Boolean, default=False, nullable=False) + # JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked + sub_choices = Column(Text, nullable=True) + is_favorite = Column(Boolean, default=False, nullable=False) + favorite_sort_order = Column(Integer, default=0, nullable=False) + + product = relationship("Product", back_populates="options") + + +class ProductQuickOption(Base): + __tablename__ = "product_quick_options" + + id = Column(Integer, primary_key=True, index=True) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + name = Column(String, nullable=False) + price = Column(Float, default=0.0, nullable=False) + allow_multiple = Column(Boolean, default=False, nullable=False) + sort_order = Column(Integer, default=0, nullable=False) + is_favorite = Column(Boolean, default=False, nullable=False) + favorite_sort_order = Column(Integer, default=0, nullable=False) + is_compact = Column(Boolean, default=False, nullable=False) + + product = relationship("Product", back_populates="quick_options") + + +class ProductIngredient(Base): + __tablename__ = "product_ingredients" + + id = Column(Integer, primary_key=True, index=True) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + name = Column(String, nullable=False) + extra_cost = Column(Float, default=0.0) + is_favorite = Column(Boolean, default=False, nullable=False) + favorite_sort_order = Column(Integer, default=0, nullable=False) + + product = relationship("Product", back_populates="ingredients") + + +class ProductPreferenceSet(Base): + __tablename__ = "product_preference_sets" + + id = Column(Integer, primary_key=True, index=True) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + name = Column(String, nullable=False) + default_choice_id = Column(Integer, nullable=True) + # JSON: {name, default_choice_index, choices:[{name,extra_cost,is_default}]} + # Shared sub-set shown for all choices that don't have disables_subset=True + shared_subset = Column(Text, nullable=True) + is_favorite = Column(Boolean, default=False, nullable=False) + favorite_sort_order = Column(Integer, default=0, nullable=False) + + product = relationship("Product", back_populates="preference_sets") + choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan") + + +class ProductPreferenceChoice(Base): + __tablename__ = "product_preference_choices" + + id = Column(Integer, primary_key=True, index=True) + set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False) + name = Column(String, nullable=False) + extra_cost = Column(Float, default=0.0) + # JSON array of sub-choice objects: [{name, extra_cost, is_default}] + # Per-choice inline sub-preference shown only when this choice is selected. + sub_choices = Column(Text, nullable=True) + # When True this choice hides the set-level shared_subset on the PWA. + disables_subset = Column(Boolean, default=False, nullable=False) + + set = relationship("ProductPreferenceSet", back_populates="choices") diff --git a/local_backend/models/settings.py b/local_backend/models/settings.py new file mode 100644 index 0000000..b671a7c --- /dev/null +++ b/local_backend/models/settings.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from database import Base + + +def _utcnow(): + return datetime.now(timezone.utc) + + +class PosSettings(Base): + __tablename__ = "pos_settings" + + key = Column(String, primary_key=True) + value = Column(String, nullable=False) + updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False) + updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + + updated_by = relationship("User", foreign_keys=[updated_by_id]) diff --git a/local_backend/models/shift.py b/local_backend/models/shift.py new file mode 100644 index 0000000..d06a1c2 --- /dev/null +++ b/local_backend/models/shift.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Text +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from database import Base + + +def _utcnow(): + return datetime.now(timezone.utc) + + +class WaiterShift(Base): + __tablename__ = "waiter_shifts" + + id = Column(Integer, primary_key=True, index=True) + waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False) + business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=False) + started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False) + ended_at = Column(DateTime(timezone=True), nullable=True) + starting_cash = Column(Float, nullable=True) + total_collected = Column(Float, nullable=True) # snapshot written at shift end + notes = Column(Text, nullable=True) + + waiter = relationship("User", foreign_keys=[waiter_id]) + business_day = relationship("BusinessDay", back_populates="shifts") + breaks = relationship("ShiftBreak", back_populates="shift", cascade="all, delete-orphan") + + +class ShiftBreak(Base): + __tablename__ = "shift_breaks" + + id = Column(Integer, primary_key=True, index=True) + shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=False) + started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False) + ended_at = Column(DateTime(timezone=True), nullable=True) + + shift = relationship("WaiterShift", back_populates="breaks") diff --git a/local_backend/models/table.py b/local_backend/models/table.py new file mode 100644 index 0000000..d1f4fe8 --- /dev/null +++ b/local_backend/models/table.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey +from sqlalchemy.orm import relationship +from database import Base + + +class TableGroup(Base): + __tablename__ = "table_groups" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False, unique=True) + prefix = Column(String, nullable=True) + sort_order = Column(Integer, default=0) + color = Column(String, nullable=True) + + tables = relationship("Table", back_populates="group") + waiter_zones = relationship("WaiterZone", back_populates="group") + + +class Table(Base): + __tablename__ = "tables" + + id = Column(Integer, primary_key=True, index=True) + number = Column(Integer, nullable=False) + label = Column(String, nullable=True) + group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + floor_x = Column(Float, nullable=True) + floor_y = Column(Float, nullable=True) + + group = relationship("TableGroup", back_populates="tables") + orders = relationship("Order", back_populates="table") diff --git a/local_backend/models/user.py b/local_backend/models/user.py new file mode 100644 index 0000000..1cefe90 --- /dev/null +++ b/local_backend/models/user.py @@ -0,0 +1,70 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from database import Base + + +def _utcnow(): + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, unique=True, nullable=False, index=True) + pin_hash = Column(String, nullable=False) + password_hash = Column(String, nullable=True) + email = Column(String, nullable=True) + role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin' + is_active = Column(Boolean, default=True, nullable=False) + full_name = Column(String, nullable=True) + nickname = Column(String, nullable=True) + mobile_phone = Column(String, nullable=True) + avatar_url = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), default=_utcnow) + + orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener") + orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer") + order_items = relationship("OrderItem", foreign_keys="OrderItem.added_by", back_populates="added_by_user") + items_paid = relationship("OrderItem", foreign_keys="OrderItem.paid_by", back_populates="paid_by_user") + order_assignments = relationship("OrderWaiter", back_populates="waiter") + zone_assignments = relationship("WaiterZone", back_populates="waiter", cascade="all, delete-orphan") + + primary_assignments = relationship( + "AssistantAssignment", + foreign_keys="AssistantAssignment.primary_waiter_id", + back_populates="primary_waiter", + ) + assistant_assignments = relationship( + "AssistantAssignment", + foreign_keys="AssistantAssignment.assistant_waiter_id", + back_populates="assistant_waiter", + ) + + +class WaiterZone(Base): + """Maps a waiter to a table group they are allowed to operate in. + If a waiter has NO rows here, they see NOTHING. + A sentinel row with group_id=NULL means 'all zones'.""" + __tablename__ = "waiter_zones" + + id = Column(Integer, primary_key=True, index=True) + waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False) + group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) # NULL = all zones + assigned_at = Column(DateTime(timezone=True), default=_utcnow) + + waiter = relationship("User", back_populates="zone_assignments") + group = relationship("TableGroup", back_populates="waiter_zones") + + +class AssistantAssignment(Base): + __tablename__ = "assistant_assignments" + + id = Column(Integer, primary_key=True, index=True) + primary_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False) + assistant_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False) + assigned_at = Column(DateTime(timezone=True), default=_utcnow) + + primary_waiter = relationship("User", foreign_keys=[primary_waiter_id], back_populates="primary_assignments") + assistant_waiter = relationship("User", foreign_keys=[assistant_waiter_id], back_populates="assistant_assignments") diff --git a/local_backend/print_size_test.py b/local_backend/print_size_test.py new file mode 100644 index 0000000..1c4df82 --- /dev/null +++ b/local_backend/print_size_test.py @@ -0,0 +1,137 @@ +""" +Font size comparison test — Jolimark TP850UE +Usage: python print_size_test.py [IP] [PORT] +Default: 10.98.20.25:9100 + +Prints a single page showing all available size options side by side, +to help decide which sizes to expose in the settings UI. + +Hardware facts: + ESC ! (0x1B 0x21 n): + 0x10 = double-height only (tall + narrow — breaks aspect ratio) + 0x20 = double-width only (short + wide — breaks aspect ratio) + 0x30 = double-height + double-width (2x in both axes — correct aspect ratio) + There is NO 1.5x in ESC/POS hardware. + GS ! (0x1D 0x21 n) can go 3x, 4x … 8x but they are extremely large. +""" +import sys + +PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25" +PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100 + +try: + from escpos.printer import Network +except ImportError: + print("escpos not installed. Run: pip install python-escpos") + sys.exit(1) + +def gr(text): + return text.encode('cp737', errors='replace') + +def raw(p, b): + p._raw(b) + +def section(p, title): + raw(p, b'\x1b\x21\x00') + raw(p, b'\x1b\x45\x00') + raw(p, b'\x1b\x61\x01') + p._raw(gr(f"--- {title} ---\n")) + raw(p, b'\x1b\x61\x00') + +def print_sample(p, esc_bang, gs_size, label_en, label_gr): + """Print one size sample with label.""" + # Label at normal size + raw(p, b'\x1b\x21\x00') + raw(p, b'\x1b\x45\x00') + p._raw(gr(f"{label_en}:\n")) + + # Apply size via ESC ! and/or GS ! + if gs_size is not None: + raw(p, bytes([0x1d, 0x21, gs_size])) + raw(p, bytes([0x1b, 0x21, esc_bang])) + + p._raw(gr(f"Club Sandwich. x1\n")) + p._raw(gr(f"* Χωρις αλατι\n")) + p._raw(gr(f"+ Extra Bacon x2\n")) + + # Reset + raw(p, b'\x1d\x21\x00') + raw(p, b'\x1b\x21\x00') + raw(p, b'\n') + +def divider(p): + raw(p, b'\x1b\x21\x00') + p._raw(gr("-" * 48 + "\n")) + +print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}...") +p = Network(PRINTER_IP, PRINTER_PORT, timeout=10) +raw(p, b'\x1b\x40') # ESC @ reset +raw(p, b'\x1b\x74\x1d') # CP737 Greek + +raw(p, b'\x1b\x61\x01') +raw(p, b'\x1b\x21\x30') +raw(p, b'\x1b\x45\x01') +p._raw(gr("SIZE COMPARISON TEST\n")) +raw(p, b'\x1b\x21\x00') +raw(p, b'\x1b\x45\x00') +raw(p, b'\x1b\x61\x00') +p._raw(gr("Which sizes look good for ticket printing?\n\n")) + +# ── Section 1: The two aspect-ratio-correct options ─────────────────────── +section(p, "CORRECT ASPECT RATIO") +p._raw(gr("\n")) + +print_sample(p, + esc_bang=0x00, gs_size=None, + label_en="[1] SMALL (1x1 — normal)", + label_gr="") + +print_sample(p, + esc_bang=0x30, gs_size=None, + label_en="[2] LARGE (2x2 — double height+width)", + label_gr="") + +# ── Section 2: The broken single-axis options (for comparison) ──────────── +divider(p) +section(p, "BROKEN ASPECT RATIO (for comparison)") +p._raw(gr("These scale only ONE axis — shown so\nyou can confirm they look wrong.\n\n")) + +print_sample(p, + esc_bang=0x10, gs_size=None, + label_en="[3] Tall only (2x height, 1x width)", + label_gr="") + +print_sample(p, + esc_bang=0x20, gs_size=None, + label_en="[4] Wide only (1x height, 2x width)", + label_gr="") + +# ── Section 3: GS ! options — 3x and beyond ────────────────────────────── +divider(p) +section(p, "GS! LARGER SIZES (3x3, 4x4)") +p._raw(gr("These are technically available but\nvery large. Shown for completeness.\n\n")) + +print_sample(p, + esc_bang=0x00, gs_size=0x22, + label_en="[5] GS! 3x3", + label_gr="") + +print_sample(p, + esc_bang=0x00, gs_size=0x33, + label_en="[6] GS! 4x4", + label_gr="") + +# ── Conclusion ──────────────────────────────────────────────────────────── +divider(p) +raw(p, b'\x1b\x61\x01') +raw(p, b'\x1b\x21\x00') +p._raw(gr("CONCLUSION:\n")) +p._raw(gr("[1] Small = use for modifiers/notes\n")) +p._raw(gr("[2] Large = use for item names/headers\n")) +p._raw(gr("No true 1.5x exists in hardware.\n")) +p._raw(gr("GS! 3x3/4x4 available if desired.\n")) + +raw(p, b'\n\n\n') +p.cut() +p.close() +print("Done.") diff --git a/local_backend/print_test.py b/local_backend/print_test.py new file mode 100644 index 0000000..a304eb4 --- /dev/null +++ b/local_backend/print_test.py @@ -0,0 +1,343 @@ +""" +Printer comprehensive test script — Jolimark TP850UE +Usage: python print_test.py [IP] [PORT] +Default: 10.98.20.25:9100 + +Prints 6 pages: + Page 1 — ESC ! modes, Font A, English + Page 2 — ESC ! modes, Font B, English + Page 3 — ESC ! modes, Font A, Greek + Page 4 — ESC ! modes, Font B, Greek + Page 5 — GS ! character size multipliers (both fonts) + Page 6 — Beep tests + misc (underline, invert, symbols) + +ESC ! (0x1B 0x21 n) correct bit map for TP850UE: + Bit 0 (0x01) — Font B instead of Font A + Bit 3 (0x08) — Emphasize / Bold + Bit 4 (0x10) — Double-height + Bit 5 (0x20) — Double-width + Bit 7 (0x80) — Underline + +GS ! (0x1D 0x21 n) character size multiplier: + Low nibble (bits 0-3): height multiplier (0=1x, 1=2x, 2=3x … 7=8x) + High nibble (bits 4-7): width multiplier (0=1x, 1=2x, 2=3x … 7=8x) + e.g. n=0x00 → 1×1, n=0x11 → 2×2, n=0x22 → 3×3, n=0x77 → 8×8 +""" +import sys +import time + +PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25" +PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100 + +from escpos.printer import Network + + +# ── Low-level helpers ────────────────────────────────────────────────────────── + +def _gr(text: str) -> bytes: + return text.encode('cp737', errors='replace') + +def _open(): + p = Network(PRINTER_IP, PRINTER_PORT, timeout=10) + p._raw(b'\x1b\x40') # ESC @ — full reset + p._raw(b'\x1b\x74\x1d') # ESC t 29 — CP737 Greek code page + return p + +def _t(p, text: str): + p._raw(_gr(text)) + +def _reset(p): + """Reset to: Font A, normal size, no bold, left-align.""" + p._raw(b'\x1b\x4d\x00') # ESC M 0 — Font A + p._raw(b'\x1b\x21\x00') # ESC ! 0 — normal + p._raw(b'\x1d\x21\x00') # GS ! 0 — 1×1 size + p._raw(b'\x1b\x45\x00') # ESC E 0 — bold off + p._raw(b'\x1b\x61\x00') # ESC a 0 — left align + +def _center(p): p._raw(b'\x1b\x61\x01') +def _left(p): p._raw(b'\x1b\x61\x00') + +def _divider(p, char="-", width=48): + _left(p) + _t(p, char * width + "\n") + +def _page_header(p, title: str): + _center(p) + p._raw(b'\x1b\x21\x28') # double-width + bold (bits 3+5 = 0x28) + _t(p, title + "\n") + _reset(p) + _divider(p, "=") + + +# ── ESC ! mode table ─────────────────────────────────────────────────────────── +# +# Each entry: (esc_bang_byte, esc_e_bold, label) +# esc_bang_byte sets the mode via ESC ! n +# esc_e_bold adds ESC E on top (independent bold layer) +# We test every useful combination so you can see the exact visual result. + +ESC_BANG_MODES = [ + # (byte, extra_bold, label) + (0x00, False, "0x00 Normal"), + (0x00, True, "0x00 +ESC E Normal + Bold (ESC E)"), + (0x08, False, "0x08 Bold only (bit3)"), + (0x10, False, "0x10 Double-height (bit4)"), + (0x10, True, "0x10 +ESC E Double-height + Bold"), + (0x18, False, "0x18 Double-height + Bold (bits 3+4)"), + (0x20, False, "0x20 Double-width (bit5)"), + (0x20, True, "0x20 +ESC E Double-width + Bold"), + (0x28, False, "0x28 Double-width + Bold (bits 3+5)"), + (0x30, False, "0x30 Double-width + Double-height (bits 4+5)"), + (0x38, False, "0x38 Double-width + Double-height + Bold (bits 3+4+5)"), +] + + +def _esc_bang_section(p, english: bool): + lang = "EN" if english else "GR" + sample_normal = "TEST PRINT Hello 123" if english else "ΔΟΚΙΜΗ ΕΚΤΥΠΩΣΗΣ" + sample_lower = "test print hello 123" if english else "δοκιμη εκτυπωσης" + + for (byte_val, extra_bold, label) in ESC_BANG_MODES: + _left(p) + # Print the label in small normal text first + p._raw(b'\x1b\x21\x00') + p._raw(b'\x1b\x45\x00') + _t(p, f"[{label}]\n") + + # Apply mode + p._raw(bytes([0x1b, 0x21, byte_val])) + if extra_bold: + p._raw(b'\x1b\x45\x01') + + _t(p, sample_normal + "\n") + _t(p, sample_lower + "\n") + + # Reset + _reset(p) + _t(p, "\n") + + _divider(p) + + +# ── Pages 1–4: ESC ! modes ──────────────────────────────────────────────────── + +def page_esc_bang(font_b: bool, english: bool): + font_label = "Font B (8x16 small)" if font_b else "Font A (12x24 standard)" + lang_label = "GREEK" if not english else "ENGLISH" + p = _open() + + # Select font + p._raw(b'\x1b\x4d\x01' if font_b else b'\x1b\x4d\x00') + + _page_header(p, f"ESC! MODES — {lang_label} — {font_label[:6]}") + _t(p, f"Font: {font_label}\n") + _divider(p) + + _esc_bang_section(p, english) + + p._raw(b'\n\n\n') + p.cut() + p.close() + + +# ── Page 5: GS ! size multipliers ───────────────────────────────────────────── + +# Combinations worth seeing: square multipliers + some asymmetric +GS_SIZES = [ + (0x00, "1x1 normal"), + (0x01, "1w x 2h"), + (0x10, "2w x 1h"), + (0x11, "2x2"), + (0x22, "3x3"), + (0x33, "4x4"), + (0x44, "5x5"), + (0x55, "6x6"), + (0x02, "1w x 3h"), + (0x20, "3w x 1h"), + (0x21, "3w x 2h"), + (0x12, "2w x 3h"), +] + +def page_gs_sizes(): + p = _open() + _page_header(p, "GS! SIZE MULTIPLIERS") + _t(p, "GS ! n (0x1D 0x21 n)\n") + _t(p, "Low nibble=height, High nibble=width\n") + _divider(p) + + for (byte_val, label) in GS_SIZES: + _left(p) + # Label in tiny normal text + p._raw(b'\x1b\x21\x00') + p._raw(b'\x1d\x21\x00') + _t(p, f"[n=0x{byte_val:02X} {label}]\n") + + # Font A sample + p._raw(b'\x1b\x4d\x00') + p._raw(bytes([0x1d, 0x21, byte_val])) + _t(p, "Aa SAMPLE\n") + p._raw(b'\x1d\x21\x00') + + # Font B sample on same size + p._raw(b'\x1b\x4d\x01') + p._raw(bytes([0x1d, 0x21, byte_val])) + _t(p, "Bb SMALL\n") + p._raw(b'\x1d\x21\x00') + p._raw(b'\x1b\x4d\x00') # back to Font A + + _t(p, "\n") + + _divider(p) + + # Also show GS ! combined with ESC ! bold + _t(p, "\n") + _divider(p, "=") + p._raw(b'\x1b\x21\x00') + _t(p, "GS! + ESC E bold combined:\n") + _divider(p, "=") + for (byte_val, label) in [(0x11,"2x2"), (0x22,"3x3"), (0x33,"4x4")]: + p._raw(b'\x1b\x21\x00') + p._raw(b'\x1d\x21\x00') + _t(p, f"[{label} + bold]\n") + p._raw(b'\x1b\x45\x01') + p._raw(bytes([0x1d, 0x21, byte_val])) + _t(p, "BOLD LARGE\n") + p._raw(b'\x1b\x45\x00') + p._raw(b'\x1d\x21\x00') + _t(p, "\n") + + p._raw(b'\n\n\n') + p.cut() + p.close() + + +# ── Page 6: Beep + misc ──────────────────────────────────────────────────────── + +def page_beep_misc(): + p = _open() + _page_header(p, "BEEP + MISC TESTS") + + # ── Beep section ── + _t(p, "BEEP TESTS\n") + _divider(p, "-") + _t(p, "Sending beeps now...\n\n") + + # BEL — single beep (0x07) + _t(p, "[1] BEL single beep (0x07)\n") + p._raw(b'\x07') + time.sleep(0.5) + + # ESC BEL n1 n2 n3 — beep for appointment + # n1=beep length (100ms units), n2=intermission (100ms), n3=count + _t(p, "[2] ESC BEL: 1 beep, 200ms long\n") + p._raw(bytes([0x1b, 0x07, 2, 2, 1])) # 200ms on, 200ms off, 1 beep + time.sleep(0.8) + + _t(p, "[3] ESC BEL: 3 short beeps\n") + p._raw(bytes([0x1b, 0x07, 1, 1, 3])) # 100ms on, 100ms off, 3 beeps + time.sleep(1.5) + + _t(p, "[4] ESC BEL: 1 long beep (500ms)\n") + p._raw(bytes([0x1b, 0x07, 5, 2, 1])) # 500ms on, 200ms off, 1 beep + time.sleep(1.2) + + _t(p, "[5] GS BEL: 2 beeps\n") + p._raw(bytes([0x1d, 0x07, 2, 3, 2])) # 2 beeps, 300ms long, 200ms off + time.sleep(1.5) + + _t(p, "Beep tests done.\n") + _divider(p) + + # ── Underline ── + _t(p, "\nUNDERLINE\n") + _divider(p, "-") + for ul in [1, 2]: + p._raw(bytes([0x1b, 0x2d, ul])) + _t(p, f"Underline mode {ul}: Hello World 123\n") + p._raw(b'\x1b\x2d\x00') + _t(p, "\n") + _divider(p) + + # ── White-on-black invert ── + _t(p, "\nWHITE-ON-BLACK (GS B)\n") + _divider(p, "-") + p._raw(b'\x1d\x42\x01') + _t(p, " INVERTED NORMAL \n") + p._raw(b'\x1d\x21\x11') # 2x2 inverted + _t(p, " INVERTED 2x2 \n") + p._raw(b'\x1d\x21\x00') + p._raw(b'\x1d\x42\x00') + _t(p, "Normal after invert\n") + _divider(p) + + # ── 90-degree rotation ── + _t(p, "\n90-DEGREE ROTATION (ESC V)\n") + _divider(p, "-") + p._raw(b'\x1b\x56\x01') + _t(p, "ROTATED TEXT\n") + p._raw(b'\x1b\x56\x00') + _t(p, "Normal again\n") + _divider(p) + + # ── CP737 useful symbols at normal size ── + _t(p, "\nUSEFUL CP737 SYMBOLS\n") + _divider(p, "-") + symbols = [ + (0xFB, "tick / checkmark"), + (0xFE, "filled square"), + (0xF9, "middle dot"), + (0xFA, "small bullet"), + (0xF8, "degree"), + (0xDB, "full block"), + (0xDC, "lower half block"), + (0xDF, "upper half block"), + (0xB0, "light shade"), + (0xB1, "medium shade"), + (0xB2, "dark shade"), + (0xC4, "thin horiz line"), + (0xCD, "double horiz line"), + (0xBA, "vertical bar"), + (0xC9, "top-left corner dbl"), + (0xBB, "top-right corner dbl"), + (0xC8, "bot-left corner dbl"), + (0xBC, "bot-right corner dbl"), + ] + for code, desc in symbols: + p._raw(bytes([code, 0x20, code, 0x20, code, 0x20])) + _t(p, f" {desc}\n") + + _divider(p) + + p._raw(b'\n\n\n') + p.cut() + p.close() + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main(): + print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}") + print("Printing 6 pages...\n") + + page_esc_bang(font_b=False, english=True) + print("Page 1 done — ESC! modes, Font A, English") + + page_esc_bang(font_b=True, english=True) + print("Page 2 done — ESC! modes, Font B, English") + + page_esc_bang(font_b=False, english=False) + print("Page 3 done — ESC! modes, Font A, Greek") + + page_esc_bang(font_b=True, english=False) + print("Page 4 done — ESC! modes, Font B, Greek") + + page_gs_sizes() + print("Page 5 done — GS! size multipliers") + + page_beep_misc() + print("Page 6 done — Beep tests + misc") + + print("\nAll done.") + +if __name__ == "__main__": + main() diff --git a/local_backend/requirements.txt b/local_backend/requirements.txt new file mode 100644 index 0000000..714a374 --- /dev/null +++ b/local_backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +sqlalchemy==2.0.36 +pydantic-settings==2.6.1 +python-escpos==3.1 +Pillow==10.4.0 +bcrypt==4.2.0 +pyjwt==2.9.0 +httpx==0.27.2 +python-multipart==0.0.9 diff --git a/local_backend/routers/__init__.py b/local_backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_backend/routers/auth.py b/local_backend/routers/auth.py new file mode 100644 index 0000000..592bab1 --- /dev/null +++ b/local_backend/routers/auth.py @@ -0,0 +1,174 @@ +import bcrypt +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from database import get_db +from models.user import User +from schemas.auth import LoginRequest, TokenResponse, UpdateMeRequest +from pydantic import BaseModel as _PydanticBase + +class LoginByIdRequest(_PydanticBase): + waiter_id: int + pin: str +from schemas.user import UserOut +from routers.deps import get_current_user, make_token, decode_token, blacklist_token + +router = APIRouter() + + +class NoAuthLoginRequest(_PydanticBase): + username: str + + +@router.post("/login-no-auth", response_model=TokenResponse) +def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)): + """Login with no credentials — only works when security.login_method = 'none'.""" + from models.settings import PosSettings + setting = db.query(PosSettings).filter(PosSettings.key == "security.login_method").first() + if not setting or setting.value != "none": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No-auth login is not enabled.") + user = db.query(User).filter(User.username == body.username, User.is_active == True).first() + if not user or user.role not in ("manager", "sysadmin"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + token = make_token(user) + return TokenResponse(access_token=token, user=UserOut.model_validate(user)) + + +@router.post("/login", response_model=TokenResponse) +def login(body: LoginRequest, db: Session = Depends(get_db)): + user = db.query(User).filter(User.username == body.username, User.is_active == True).first() + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + authenticated = False + if body.password and user.password_hash: + authenticated = bcrypt.checkpw(body.password.encode(), user.password_hash.encode()) + elif body.pin and user.pin_hash: + authenticated = bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()) + + if not authenticated: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + token = make_token(user) + return TokenResponse(access_token=token, user=UserOut.model_validate(user)) + + +@router.post("/login-by-id", response_model=TokenResponse) +def login_by_id(body: LoginByIdRequest, db: Session = Depends(get_db)): + """Login using waiter id + PIN (used by the waiter-picker login screen).""" + user = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first() + if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Λανθασμένο PIN") + token = make_token(user) + return TokenResponse(access_token=token, user=UserOut.model_validate(user)) + + +@router.post("/refresh", response_model=TokenResponse) +def refresh(token: str, db: Session = Depends(get_db)): + payload = decode_token(token) + user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first() + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + blacklist_token(token) + new_token = make_token(user) + return TokenResponse(access_token=new_token, user=UserOut.model_validate(user)) + + +@router.post("/logout") +def logout(token: str): + blacklist_token(token) + return {"status": "logged out"} + + +@router.get("/me", response_model=UserOut) +def me(user: User = Depends(get_current_user)): + return user + + +# ─── Public manager list (login screen — no auth required) ─────────────────── + +class PublicManagerOut(_PydanticBase): + id: int + username: str + full_name: str | None + model_config = {"from_attributes": True} + + +@router.get("/managers", response_model=list[PublicManagerOut]) +def public_manager_list(db: Session = Depends(get_db)): + """Public endpoint — returns active manager/sysadmin accounts for login screen.""" + managers = db.query(User).filter( + User.role.in_(["manager", "sysadmin"]), + User.is_active == True, + ).all() + return [PublicManagerOut(id=m.id, username=m.username, full_name=m.full_name) for m in managers] + + +# ─── Public waiter list (login screen — no auth required) ──────────────────── + +from pydantic import BaseModel as _BaseModel + +class PublicWaiterOut(_BaseModel): + id: int + full_name: str | None + nickname: str | None + avatar_url: str | None + on_shift: bool + model_config = {"from_attributes": True} + + +@router.get("/waiters", response_model=list[PublicWaiterOut]) +def public_waiter_list(db: Session = Depends(get_db)): + """Public endpoint — returns active waiters with on-shift flag. No auth required.""" + from models.shift import WaiterShift + waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all() + on_shift_ids = { + row.waiter_id + for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all() + } + return [ + PublicWaiterOut( + id=w.id, + full_name=w.full_name, + nickname=w.nickname, + avatar_url=w.avatar_url, + on_shift=w.id in on_shift_ids, + ) + for w in waiters + ] + + +@router.patch("/me", response_model=UserOut) +def update_me(body: UpdateMeRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + # Password change — requires current_password verification + if body.new_password is not None: + if not body.current_password: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="current_password is required to set a new password") + if not user.password_hash: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Account has no password set") + if not bcrypt.checkpw(body.current_password.encode(), user.password_hash.encode()): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect") + if len(body.new_password) < 6: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New password must be at least 6 characters") + user.password_hash = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode() + + # PIN change — no current PIN required (already authenticated) + if body.new_pin is not None: + if len(body.new_pin) < 4: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="PIN must be at least 4 digits") + user.pin_hash = bcrypt.hashpw(body.new_pin.encode(), bcrypt.gensalt()).decode() + + # Username change — check uniqueness + if body.username is not None and body.username != user.username: + conflict = db.query(User).filter(User.username == body.username, User.id != user.id).first() + if conflict: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken") + user.username = body.username + + # Display name + if body.full_name is not None: + user.full_name = body.full_name + + db.commit() + db.refresh(user) + return user diff --git a/local_backend/routers/business_day.py b/local_backend/routers/business_day.py new file mode 100644 index 0000000..e9846c8 --- /dev/null +++ b/local_backend/routers/business_day.py @@ -0,0 +1,195 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func +from sqlalchemy.orm import Session +from typing import Optional +from datetime import datetime, timezone + +from database import get_db +from models.business_day import BusinessDay +from models.order import Order, OrderItem, OrderAuditLog +from models.shift import WaiterShift +from models.flag import TableFlagAssignment +from models.message import StaffMessage, StaffMessageAck +from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest +from routers.deps import get_current_user, require_manager +from models.user import User +from middleware.license_check import license_state + +router = APIRouter() + + +def _dt(dt): + if dt is None: + return None + return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat() + + +@router.get("/current", response_model=Optional[BusinessDayOut]) +def get_current_business_day( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + return db.query(BusinessDay).filter(BusinessDay.status == "open").first() + + +@router.post("/open", response_model=BusinessDayOut, status_code=status.HTTP_201_CREATED) +def open_business_day( + body: OpenBusinessDayRequest, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + existing = db.query(BusinessDay).filter(BusinessDay.status == "open").first() + if existing: + raise HTTPException(status_code=400, detail="A business day is already open") + + # Gate: admin lock (already enforced or pending) + if license_state.get("locked") or license_state.get("lock_pending"): + raise HTTPException( + status_code=423, + detail={ + "code": "SYSTEM_LOCKED", + "message": "Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.", + }, + ) + + # Gate: license expired and expiry grace period also over + if not license_state.get("licensed", True): + expires_at = license_state.get("expires_at", "") + raise HTTPException( + status_code=402, + detail={ + "code": "LICENSE_EXPIRED", + "message": "Η άδεια χρήσης έχει λήξει. Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.", + "expires_at": expires_at, + }, + ) + + day = BusinessDay(opened_by_id=user.id, notes=body.notes) + db.add(day) + db.commit() + db.refresh(day) + return day + + +@router.post("/close", response_model=BusinessDayOut) +def close_business_day( + body: CloseBusinessDayRequest, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() + if not day: + raise HTTPException(status_code=404, detail="No open business day") + + open_orders = db.query(Order).filter( + Order.business_day_id == day.id, + Order.status.in_(["open", "partially_paid"]), + ).all() + + if open_orders and not body.force: + # Count orders that have at least one active (unpaid) item — covers both + # "open" (fully unpaid) and "partially_paid" (partially unpaid) orders. + with_pending = sum( + 1 for o in open_orders + if db.query(OrderItem).filter( + OrderItem.order_id == o.id, + OrderItem.status == "active", + ).first() is not None + ) + raise HTTPException( + status_code=409, + detail={ + "message": f"{len(open_orders)} table(s) still open, {with_pending} with unpaid items.", + "open_orders": len(open_orders), + "partially_paid": with_pending, + }, + ) + + now = datetime.now(timezone.utc) + + # Close all non-terminal orders for this business day (open, partially_paid, paid) + all_unclosed = db.query(Order).filter( + Order.business_day_id == day.id, + Order.status.in_(["open", "partially_paid", "paid"]), + ).all() + for order in all_unclosed: + was_unpaid = order.status in ("open", "partially_paid") + order.status = "closed" + order.closed_at = now + order.closed_by = user.id + if was_unpaid: + db.add(OrderAuditLog( + order_id=order.id, + event_type="ORDER_CLOSED", + waiter_id=user.id, + note="Force-closed at end of business day", + )) + + active_shifts = db.query(WaiterShift).filter( + WaiterShift.business_day_id == day.id, + WaiterShift.ended_at == None, + ).all() + for shift in active_shifts: + items = db.query(OrderItem).filter( + OrderItem.paid_in_shift_id == shift.id, + OrderItem.status == "paid", + ).all() + shift.total_collected = sum(i.unit_price * i.quantity for i in items) + shift.ended_at = now + + # Clear all table flags and staff messages — fresh slate for the next day + db.query(TableFlagAssignment).delete(synchronize_session=False) + db.query(StaffMessageAck).delete(synchronize_session=False) + db.query(StaffMessage).delete(synchronize_session=False) + + day.status = "closed" + day.closed_at = now + day.closed_by_id = user.id + if body.notes: + day.notes = body.notes + + db.commit() + db.refresh(day) + + # Deferred lock: if cloud requested a lock while the workday was open, + # enforce it now that the day has closed. + if license_state.get("lock_pending"): + license_state["lock_pending"] = False + license_state["locked"] = True + from services.cloud_sync import _persist_state + _persist_state() + + return day + + +@router.get("/history") +def business_day_history( + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + days = db.query(BusinessDay).order_by(BusinessDay.opened_at.desc()).all() + result = [] + for day in days: + order_count = db.query(Order).filter(Order.business_day_id == day.id).count() + revenue = ( + db.query(func.sum(OrderItem.unit_price * OrderItem.quantity)) + .join(Order) + .filter(Order.business_day_id == day.id, OrderItem.status == "paid") + .scalar() or 0.0 + ) + w_opener = day.opener + w_closer = day.closer + result.append({ + "id": day.id, + "status": day.status, + "opened_at": _dt(day.opened_at), + "closed_at": _dt(day.closed_at), + "opened_by_id": day.opened_by_id, + "opened_by_name": (w_opener.full_name or w_opener.username) if w_opener else None, + "closed_by_id": day.closed_by_id, + "closed_by_name": (w_closer.full_name or w_closer.username) if w_closer else None, + "notes": day.notes, + "order_count": order_count, + "revenue": round(revenue, 2), + }) + return {"business_days": result} diff --git a/local_backend/routers/data_transfer.py b/local_backend/routers/data_transfer.py new file mode 100644 index 0000000..ddbe983 --- /dev/null +++ b/local_backend/routers/data_transfer.py @@ -0,0 +1,342 @@ +import json +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session + +from database import get_db +from models.product import ( + Category, Product, ProductOption, ProductQuickOption, + ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice, +) +from models.table import Table, TableGroup +from models.user import User +from routers.deps import require_manager + +router = APIRouter() + +EXPORT_VERSION = 1 + + +def _serialize_product(p: Product) -> dict: + quick_options = [ + {"name": qo.name, "price": qo.price, "allow_multiple": qo.allow_multiple, + "sort_order": qo.sort_order, "is_favorite": qo.is_favorite, + "favorite_sort_order": qo.favorite_sort_order, "is_compact": qo.is_compact} + for qo in p.quick_options + ] + options = [] + for opt in p.options: + sub = json.loads(opt.sub_choices) if opt.sub_choices else [] + options.append({"name": opt.name, "extra_cost": opt.extra_cost, + "allow_multiple": opt.allow_multiple, "sub_choices": sub, + "is_favorite": opt.is_favorite, "favorite_sort_order": opt.favorite_sort_order}) + ingredients = [ + {"name": ing.name, "extra_cost": ing.extra_cost, + "is_favorite": ing.is_favorite, "favorite_sort_order": ing.favorite_sort_order} + for ing in p.ingredients + ] + preference_sets = [] + for ps in p.preference_sets: + shared = json.loads(ps.shared_subset) if ps.shared_subset else None + default_index = None + choices = [] + for i, ch in enumerate(ps.choices): + if ch.id == ps.default_choice_id: + default_index = i + sub = json.loads(ch.sub_choices) if ch.sub_choices else [] + choices.append({"name": ch.name, "extra_cost": ch.extra_cost, + "sub_choices": sub, "disables_subset": ch.disables_subset}) + preference_sets.append({ + "name": ps.name, "choices": choices, + "default_choice_index": default_index, "shared_subset": shared, + "is_favorite": ps.is_favorite, "favorite_sort_order": ps.favorite_sort_order, + }) + return { + "name": p.name, "base_price": p.base_price, "is_available": p.is_available, + "lifecycle_status": p.lifecycle_status, "sort_order": p.sort_order, + "printer_zone_id": None, # always stripped on export + "quick_options": quick_options, "options": options, + "ingredients": ingredients, "preference_sets": preference_sets, + } + + +def _serialize_category(cat: Category) -> dict: + products = [_serialize_product(p) for p in cat.products if p.lifecycle_status != "archived"] + return { + "name": cat.name, "color": cat.color, "sort_order": cat.sort_order, + "parent_name": cat.parent.name if cat.parent else None, + "general_sort_order": cat.general_sort_order, "auto_expanded": cat.auto_expanded, + "products": products, + } + + +@router.get("/export/catalog") +def export_catalog(db: Session = Depends(get_db), user: User = Depends(require_manager)): + categories = db.query(Category).order_by(Category.sort_order).all() + orphan_products = ( + db.query(Product) + .filter(Product.category_id == None, Product.lifecycle_status != "archived") + .order_by(Product.sort_order) + .all() + ) + data = { + "categories": [_serialize_category(c) for c in categories], + "uncategorized_products": [_serialize_product(p) for p in orphan_products], + } + payload = { + "xenia_export_version": EXPORT_VERSION, + "bundle": "catalog", + "exported_at": datetime.now(timezone.utc).isoformat(), + "data": data, + } + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + return JSONResponse( + content=payload, + headers={"Content-Disposition": f'attachment; filename="xenia-catalog-{today}.json"'}, + ) + + +@router.get("/export/tables") +def export_tables(db: Session = Depends(get_db), user: User = Depends(require_manager)): + groups = db.query(TableGroup).order_by(TableGroup.sort_order).all() + table_groups = [] + for g in groups: + tables = [ + {"number": t.number, "label": t.label, "is_active": t.is_active, + "floor_x": t.floor_x, "floor_y": t.floor_y} + for t in sorted(g.tables, key=lambda t: t.number) + ] + table_groups.append({ + "name": g.name, "prefix": g.prefix, "sort_order": g.sort_order, + "color": g.color, "tables": tables, + }) + ungrouped = ( + db.query(Table) + .filter(Table.group_id == None) + .order_by(Table.number) + .all() + ) + ungrouped_tables = [ + {"number": t.number, "label": t.label, "is_active": t.is_active, + "floor_x": t.floor_x, "floor_y": t.floor_y} + for t in ungrouped + ] + payload = { + "xenia_export_version": EXPORT_VERSION, + "bundle": "tables", + "exported_at": datetime.now(timezone.utc).isoformat(), + "data": {"table_groups": table_groups, "ungrouped_tables": ungrouped_tables}, + } + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + return JSONResponse( + content=payload, + headers={"Content-Disposition": f'attachment; filename="xenia-tables-{today}.json"'}, + ) + + +@router.post("/import/catalog") +def import_catalog(payload: dict, db: Session = Depends(get_db), user: User = Depends(require_manager)): + if payload.get("bundle") != "catalog": + raise HTTPException(status_code=400, detail="Wrong bundle type. Expected 'catalog'.") + if payload.get("xenia_export_version", 0) != EXPORT_VERSION: + raise HTTPException(status_code=400, detail="Unsupported export version.") + + data = payload.get("data", {}) + categories_data = data.get("categories", []) + + def _upsert_category(cat_data: dict, parent_id=None) -> Category: + existing = db.query(Category).filter( + Category.name == cat_data["name"], + Category.parent_id == parent_id, + ).first() + if existing: + existing.color = cat_data.get("color") + existing.sort_order = cat_data.get("sort_order", 0) + existing.general_sort_order = cat_data.get("general_sort_order", 0) + existing.auto_expanded = cat_data.get("auto_expanded", False) + db.flush() + return existing + else: + cat = Category( + name=cat_data["name"], + color=cat_data.get("color"), + sort_order=cat_data.get("sort_order", 0), + parent_id=parent_id, + general_sort_order=cat_data.get("general_sort_order", 0), + auto_expanded=cat_data.get("auto_expanded", False), + ) + db.add(cat) + db.flush() + return cat + + def _upsert_product(prod_data: dict, category_id=None): + existing = db.query(Product).filter(Product.name == prod_data["name"]).first() + if existing: + existing.base_price = prod_data["base_price"] + existing.is_available = prod_data.get("is_available", True) + existing.lifecycle_status = prod_data.get("lifecycle_status", "active") + existing.sort_order = prod_data.get("sort_order", 0) + existing.category_id = category_id + existing.printer_zone_id = None + db.flush() + product = existing + else: + product = Product( + name=prod_data["name"], + base_price=prod_data["base_price"], + is_available=prod_data.get("is_available", True), + lifecycle_status=prod_data.get("lifecycle_status", "active"), + sort_order=prod_data.get("sort_order", 0), + category_id=category_id, + printer_zone_id=None, + ) + db.add(product) + db.flush() + + # Replace sub-items (safe: sub-items have no direct order history references) + for qo in list(product.quick_options): + db.delete(qo) + for opt in list(product.options): + db.delete(opt) + for ing in list(product.ingredients): + db.delete(ing) + for ps in list(product.preference_sets): + db.delete(ps) + db.flush() + + for qo in prod_data.get("quick_options", []): + db.add(ProductQuickOption(product_id=product.id, **qo)) + + for opt in prod_data.get("options", []): + sub_json = json.dumps(opt.get("sub_choices", [])) + db.add(ProductOption( + product_id=product.id, name=opt["name"], extra_cost=opt.get("extra_cost", 0.0), + allow_multiple=opt.get("allow_multiple", False), sub_choices=sub_json, + is_favorite=opt.get("is_favorite", False), + favorite_sort_order=opt.get("favorite_sort_order", 0), + )) + + for ing in prod_data.get("ingredients", []): + db.add(ProductIngredient(product_id=product.id, **ing)) + + for ps_data in prod_data.get("preference_sets", []): + shared_json = json.dumps(ps_data["shared_subset"]) if ps_data.get("shared_subset") else None + ps = ProductPreferenceSet( + product_id=product.id, name=ps_data["name"], + shared_subset=shared_json, + is_favorite=ps_data.get("is_favorite", False), + favorite_sort_order=ps_data.get("favorite_sort_order", 0), + ) + db.add(ps) + db.flush() + created_choices = [] + for ch in ps_data.get("choices", []): + sub_json = json.dumps(ch.get("sub_choices", [])) + choice = ProductPreferenceChoice( + set_id=ps.id, name=ch["name"], extra_cost=ch.get("extra_cost", 0.0), + sub_choices=sub_json, disables_subset=ch.get("disables_subset", False), + ) + db.add(choice) + db.flush() + created_choices.append(choice) + idx = ps_data.get("default_choice_index") + if idx is not None and 0 <= idx < len(created_choices): + ps.default_choice_id = created_choices[idx].id + + # First pass: top-level categories (parent_name is None) + for cat_data in categories_data: + if cat_data.get("parent_name") is None: + cat = _upsert_category(cat_data, parent_id=None) + for prod_data in cat_data.get("products", []): + _upsert_product(prod_data, category_id=cat.id) + + # Second pass: sub-categories (parent must already exist from first pass) + for cat_data in categories_data: + if cat_data.get("parent_name") is not None: + parent = db.query(Category).filter( + Category.name == cat_data["parent_name"], + Category.parent_id == None, + ).first() + parent_id = parent.id if parent else None + cat = _upsert_category(cat_data, parent_id=parent_id) + for prod_data in cat_data.get("products", []): + _upsert_product(prod_data, category_id=cat.id) + + # Uncategorized products (no category) + for prod_data in data.get("uncategorized_products", []): + _upsert_product(prod_data, category_id=None) + + db.commit() + return {"ok": True, "message": "Catalog imported successfully."} + + +@router.post("/import/tables") +def import_tables(payload: dict, db: Session = Depends(get_db), user: User = Depends(require_manager)): + if payload.get("bundle") != "tables": + raise HTTPException(status_code=400, detail="Wrong bundle type. Expected 'tables'.") + if payload.get("xenia_export_version", 0) != EXPORT_VERSION: + raise HTTPException(status_code=400, detail="Unsupported export version.") + + data = payload.get("data", {}) + for group_data in data.get("table_groups", []): + existing_group = db.query(TableGroup).filter(TableGroup.name == group_data["name"]).first() + if existing_group: + existing_group.prefix = group_data.get("prefix") + existing_group.sort_order = group_data.get("sort_order", 0) + existing_group.color = group_data.get("color") + db.flush() + group = existing_group + else: + group = TableGroup( + name=group_data["name"], + prefix=group_data.get("prefix"), + sort_order=group_data.get("sort_order", 0), + color=group_data.get("color"), + ) + db.add(group) + db.flush() + + for table_data in group_data.get("tables", []): + existing_table = db.query(Table).filter( + Table.number == table_data["number"], + Table.group_id == group.id, + ).first() + if existing_table: + existing_table.label = table_data.get("label") + existing_table.is_active = table_data.get("is_active", True) + existing_table.floor_x = table_data.get("floor_x") + existing_table.floor_y = table_data.get("floor_y") + else: + db.add(Table( + number=table_data["number"], + label=table_data.get("label"), + group_id=group.id, + is_active=table_data.get("is_active", True), + floor_x=table_data.get("floor_x"), + floor_y=table_data.get("floor_y"), + )) + + # Ungrouped tables (no zone) + for table_data in data.get("ungrouped_tables", []): + existing_table = db.query(Table).filter( + Table.number == table_data["number"], + Table.group_id == None, + ).first() + if existing_table: + existing_table.label = table_data.get("label") + existing_table.is_active = table_data.get("is_active", True) + existing_table.floor_x = table_data.get("floor_x") + existing_table.floor_y = table_data.get("floor_y") + else: + db.add(Table( + number=table_data["number"], + label=table_data.get("label"), + group_id=None, + is_active=table_data.get("is_active", True), + floor_x=table_data.get("floor_x"), + floor_y=table_data.get("floor_y"), + )) + + db.commit() + return {"ok": True, "message": "Tables imported successfully."} diff --git a/local_backend/routers/deps.py b/local_backend/routers/deps.py new file mode 100644 index 0000000..c5c52cd --- /dev/null +++ b/local_backend/routers/deps.py @@ -0,0 +1,64 @@ +import jwt +from datetime import datetime, timedelta, timezone +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session + +from database import get_db +from config import settings +from models.user import User + +bearer = HTTPBearer() + +# In-memory token blacklist (cleared on restart — acceptable for local use) +_blacklisted_tokens: set[str] = set() + +TOKEN_EXPIRY_HOURS = 8 + + +def make_token(user: User) -> str: + payload = { + "sub": str(user.id), + "username": user.username, + "role": user.role, + "exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRY_HOURS), + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + + +def decode_token(token: str) -> dict: + if token in _blacklisted_tokens: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token revoked") + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + +def blacklist_token(token: str): + _blacklisted_tokens.add(token) + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer), + db: Session = Depends(get_db), +) -> User: + payload = decode_token(credentials.credentials) + user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first() + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user + + +def require_manager(user: User = Depends(get_current_user)) -> User: + if user.role not in ("manager", "sysadmin"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Manager access required") + return user + + +def require_sysadmin(user: User = Depends(get_current_user)) -> User: + if user.role != "sysadmin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin access required") + return user diff --git a/local_backend/routers/flags.py b/local_backend/routers/flags.py new file mode 100644 index 0000000..3ec106e --- /dev/null +++ b/local_backend/routers/flags.py @@ -0,0 +1,165 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from database import get_db +from models.flag import TableFlagDef, TableFlagAssignment +from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest +from routers.deps import get_current_user, require_manager +from models.user import User +from services.sse_bus import broadcast_sync + +router = APIRouter() + + +# ─── Flag definitions (manager only) ───────────────────────────────────────── + +@router.get("/defs", response_model=List[FlagDefOut]) +def list_flag_defs( + include_inactive: bool = False, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + q = db.query(TableFlagDef) + if not include_inactive: + q = q.filter(TableFlagDef.is_active == True) + return q.order_by(TableFlagDef.sort_order, TableFlagDef.id).all() + + +@router.post("/defs", response_model=FlagDefOut, status_code=status.HTTP_201_CREATED) +def create_flag_def( + body: FlagDefCreate, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + flag = TableFlagDef(**body.model_dump()) + db.add(flag) + db.commit() + db.refresh(flag) + return flag + + +@router.put("/defs/{flag_id}", response_model=FlagDefOut) +def update_flag_def( + flag_id: int, + body: FlagDefUpdate, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first() + if not flag: + raise HTTPException(status_code=404, detail="Flag not found") + for k, v in body.model_dump(exclude_unset=True).items(): + setattr(flag, k, v) + db.commit() + db.refresh(flag) + return flag + + +@router.patch("/defs/{flag_id}/toggle-active", response_model=FlagDefOut) +def toggle_flag_active( + flag_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first() + if not flag: + raise HTTPException(status_code=404, detail="Flag not found") + flag.is_active = not flag.is_active + db.commit() + db.refresh(flag) + return flag + + +@router.delete("/defs/{flag_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_flag_def( + flag_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first() + if not flag: + raise HTTPException(status_code=404, detail="Flag not found") + in_use = db.query(TableFlagAssignment).filter(TableFlagAssignment.flag_id == flag_id).count() + if in_use: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Η σήμανση χρησιμοποιείται σε {in_use} τραπέζι{'α' if in_use != 1 else ''}. Αφαιρέστε την πρώτα.", + ) + db.delete(flag) + db.commit() + + +# ─── All assignments (bulk endpoint for manager views) ─────────────────────── + +@router.get("/assignments", response_model=List[FlagAssignmentOut]) +def get_all_assignments( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """All active flag assignments across all tables (for manager dashboard bulk load).""" + return db.query(TableFlagAssignment).all() + + +# ─── Table flag assignments ─────────────────────────────────────────────────── + +@router.get("/table/{table_id}", response_model=List[FlagAssignmentOut]) +def get_table_flags( + table_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + return db.query(TableFlagAssignment).filter( + TableFlagAssignment.table_id == table_id + ).all() + + +@router.put("/table/{table_id}", response_model=List[FlagAssignmentOut]) +def set_table_flags( + table_id: int, + body: SetTableFlagsRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Replace all flags on a table with the given set of flag_ids.""" + # Validate all flag_ids exist and are active + if body.flag_ids: + valid = db.query(TableFlagDef).filter( + TableFlagDef.id.in_(body.flag_ids), + TableFlagDef.is_active == True, + ).count() + if valid != len(body.flag_ids): + raise HTTPException(status_code=400, detail="One or more flag IDs are invalid") + + # Delete existing assignments for this table + db.query(TableFlagAssignment).filter( + TableFlagAssignment.table_id == table_id + ).delete(synchronize_session=False) + + # Insert new assignments + for flag_id in body.flag_ids: + db.add(TableFlagAssignment( + table_id=table_id, + flag_id=flag_id, + assigned_by=user.id, + )) + + db.commit() + result = db.query(TableFlagAssignment).filter( + TableFlagAssignment.table_id == table_id + ).all() + broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": body.flag_ids}) + return result + + +@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT) +def clear_table_flags( + table_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + db.query(TableFlagAssignment).filter( + TableFlagAssignment.table_id == table_id + ).delete(synchronize_session=False) + db.commit() + broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": []}) diff --git a/local_backend/routers/messages.py b/local_backend/routers/messages.py new file mode 100644 index 0000000..ece8894 --- /dev/null +++ b/local_backend/routers/messages.py @@ -0,0 +1,215 @@ +import json +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session, joinedload +from typing import List + +from database import get_db +from models.message import StaffMessage, StaffMessageAck, QuickMessageTemplate +from models.user import User +from schemas.message import ( + SendMessageRequest, StaffMessageOut, + QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut, +) +from routers.deps import get_current_user, require_manager +from services.sse_bus import broadcast_sync + +router = APIRouter() + + +def _load_msg(db: Session, msg_id: int) -> StaffMessage: + """Reload a message with sender and acks eagerly loaded.""" + return db.query(StaffMessage).options( + joinedload(StaffMessage.sender), + joinedload(StaffMessage.acks), + ).filter(StaffMessage.id == msg_id).one() + + +def _message_out(msg: StaffMessage) -> StaffMessageOut: + sender_name = None + try: + sender_name = msg.sender.username if msg.sender else None + except Exception: + pass + return StaffMessageOut( + id=msg.id, + sender_id=msg.sender_id, + sender_name=sender_name, + body=msg.body, + target_waiter_ids=msg.target_waiter_ids, + table_ids=msg.table_ids, + created_at=msg.created_at, + acked_by=[ack.waiter_id for ack in msg.acks], + ) + + +# ─── Quick templates ────────────────────────────────────────────────────────── + +@router.get("/templates", response_model=List[QuickTemplateOut]) +def list_templates( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + return db.query(QuickMessageTemplate).filter( + QuickMessageTemplate.is_active == True + ).order_by(QuickMessageTemplate.sort_order, QuickMessageTemplate.id).all() + + +@router.post("/templates", response_model=QuickTemplateOut, status_code=status.HTTP_201_CREATED) +def create_template( + body: QuickTemplateCreate, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + t = QuickMessageTemplate(**body.model_dump()) + db.add(t) + db.commit() + db.refresh(t) + return t + + +@router.put("/templates/{template_id}", response_model=QuickTemplateOut) +def update_template( + template_id: int, + body: QuickTemplateUpdate, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first() + if not t: + raise HTTPException(status_code=404, detail="Template not found") + for k, v in body.model_dump(exclude_unset=True).items(): + setattr(t, k, v) + db.commit() + db.refresh(t) + return t + + +@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_template( + template_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first() + if not t: + raise HTTPException(status_code=404, detail="Template not found") + t.is_active = False + db.commit() + + +# ─── Staff messages ─────────────────────────────────────────────────────────── + +@router.post("/send", response_model=StaffMessageOut, status_code=status.HTTP_201_CREATED) +def send_message( + body: SendMessageRequest, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + msg = StaffMessage( + sender_id=user.id, + body=body.body, + target_waiter_ids=json.dumps(body.target_waiter_ids), + table_ids=json.dumps(body.table_ids or []), + ) + db.add(msg) + db.commit() + msg = _load_msg(db, msg.id) + out = _message_out(msg) + # Broadcast to targeted users (empty list = all connected users) + target_ids = body.target_waiter_ids if body.target_waiter_ids else None + broadcast_sync( + "message_sent", + { + "id": out.id, + "sender_id": out.sender_id, + "sender_name": out.sender_name, + "body": out.body, + "table_ids": out.table_ids, + "created_at": out.created_at.isoformat() if out.created_at else None, + }, + user_ids=target_ids, + ) + return out + + +@router.get("/unread", response_model=List[StaffMessageOut]) +def get_unread_messages( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Returns messages targeting this waiter that they haven't acked yet. + A message targets a waiter if their ID is in target_waiter_ids, + OR if target_waiter_ids is empty (broadcast to all). + """ + all_msgs = db.query(StaffMessage).options( + joinedload(StaffMessage.sender), + joinedload(StaffMessage.acks), + ).order_by(StaffMessage.created_at.desc()).limit(200).all() + acked_ids = { + ack.message_id + for ack in db.query(StaffMessageAck).filter(StaffMessageAck.waiter_id == user.id).all() + } + + result = [] + for msg in all_msgs: + if msg.id in acked_ids: + continue + targets = json.loads(msg.target_waiter_ids or "[]") + # Empty list = broadcast to all + if not targets or user.id in targets: + result.append(_message_out(msg)) + + return result + + +@router.get("/recent", response_model=List[StaffMessageOut]) +def get_recent_messages( + limit: int = 10, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Last N messages targeting this user (for notification history drawer).""" + all_msgs = db.query(StaffMessage).options( + joinedload(StaffMessage.sender), + joinedload(StaffMessage.acks), + ).order_by(StaffMessage.created_at.desc()).limit(200).all() + result = [] + for msg in all_msgs: + targets = json.loads(msg.target_waiter_ids or "[]") + if not targets or user.id in targets: + result.append(_message_out(msg)) + if len(result) >= limit: + break + return result + + +@router.post("/{message_id}/ack", status_code=status.HTTP_204_NO_CONTENT) +def ack_message( + message_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + msg = db.query(StaffMessage).filter(StaffMessage.id == message_id).first() + if not msg: + raise HTTPException(status_code=404, detail="Message not found") + existing = db.query(StaffMessageAck).filter( + StaffMessageAck.message_id == message_id, + StaffMessageAck.waiter_id == user.id, + ).first() + if not existing: + db.add(StaffMessageAck(message_id=message_id, waiter_id=user.id)) + db.commit() + + +@router.get("/all", response_model=List[StaffMessageOut]) +def list_all_messages( + limit: int = 50, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + msgs = db.query(StaffMessage).options( + joinedload(StaffMessage.sender), + joinedload(StaffMessage.acks), + ).order_by(StaffMessage.created_at.desc()).limit(limit).all() + return [_message_out(m) for m in msgs] diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py new file mode 100644 index 0000000..d157f3f --- /dev/null +++ b/local_backend/routers/orders.py @@ -0,0 +1,803 @@ +import json +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.orm import Session +from typing import List, Optional + +from database import get_db +from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog +from models.user import User, WaiterZone +from models.table import Table +from models.product import Product +from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, OfflinePaymentRequest, AssignWaiterRequest, OrderWaiterOut +from pydantic import BaseModel + +class PrintOrderRequest(BaseModel): + printer_id: int + +class TransferOrderRequest(BaseModel): + target_table_id: int + +class MergeOrderRequest(BaseModel): + target_order_id: int + +class SplitItemRequest(BaseModel): + quantity: int # how many to split off into a new item row + +class PrintSynopsisRequest(BaseModel): + printer_id: int + +class MoveItemsRequest(BaseModel): + item_ids: List[int] + target_order_id: int + +from routers.deps import get_current_user, require_manager +from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis +from services.sse_bus import broadcast_sync + +router = APIRouter() + + +def _can_access_order(order: Order, user: User, db: Session) -> bool: + """Zone-based access: any waiter whose zone covers the order's table group may act on it.""" + if user.role in ("manager", "sysadmin"): + return True + zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all() + if not zones: + return False + if any(z.group_id is None for z in zones): + return True + table = db.query(Table).filter(Table.id == order.table_id).first() + if not table: + return False + allowed_group_ids = {z.group_id for z in zones} + return table.group_id in allowed_group_ids + + +def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None, + item_ids: list = None, amount: float = None, payment_method: str = None, note: str = None): + db.add(OrderAuditLog( + order_id=order_id, + event_type=event_type, + waiter_id=waiter_id, + item_ids=json.dumps(item_ids) if item_ids is not None else None, + amount=amount, + payment_method=payment_method, + note=note, + )) + + +@router.get("/", response_model=List[OrderOut]) +def list_orders( + order_status: Optional[str] = None, + waiter_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + q = db.query(Order) + if order_status: + q = q.filter(Order.status == order_status) + if waiter_id: + q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id) + return q.all() + + +@router.get("/my", response_model=List[OrderOut]) +def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + direct = db.query(Order).join(OrderWaiter).filter( + OrderWaiter.waiter_id == user.id, + Order.status.in_(["open", "partially_paid"]), + ).all() + # Also orders where user is opener but not explicitly assigned + also_opened = db.query(Order).filter( + Order.opened_by == user.id, + Order.status.in_(["open", "partially_paid"]), + ).all() + seen = {o.id for o in direct} + return direct + [o for o in also_opened if o.id not in seen] + + +class ActiveOrderSlim(BaseModel): + id: int + table_id: int + status: str + waiter_ids: List[int] + model_config = {"from_attributes": True} + + +@router.get("/active", response_model=List[ActiveOrderSlim]) +def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + """All currently open/partially-paid/paid orders (lightweight). Accessible to all staff.""" + orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid", "paid"])).all() + return [ + ActiveOrderSlim( + id=o.id, + table_id=o.table_id, + status=o.status, + waiter_ids=[w.waiter_id for w in o.waiters], + ) + for o in orders + ] + + +@router.get("/{order_id}", response_model=OrderOut) +def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + return order + + +@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED) +def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + from models.business_day import BusinessDay + from models.shift import WaiterShift + + active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() + if not active_day: + raise HTTPException(status_code=403, detail="Restaurant is not open — manager must open the business day first") + + if user.role == "waiter": + active_shift = db.query(WaiterShift).filter( + WaiterShift.waiter_id == user.id, + WaiterShift.ended_at == None, + ).first() + if not active_shift: + raise HTTPException(status_code=403, detail="You do not have an active shift") + + existing = db.query(Order).filter( + Order.table_id == body.table_id, + Order.status.in_(["open", "partially_paid", "paid"]), + ).first() + if existing: + raise HTTPException(status_code=400, detail="Table already has an open order") + order = Order(table_id=body.table_id, opened_by=user.id, business_day_id=active_day.id) + db.add(order) + db.flush() + db.add(OrderWaiter(order_id=order.id, waiter_id=user.id)) + _audit(db, order.id, "ORDER_OPENED", waiter_id=user.id) + db.commit() + db.refresh(order) + broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "opened"}) + return order + + +@router.post("/{order_id}/items", response_model=AddItemsResponse) +def add_items( + order_id: int, + body: AddItemsRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + if order.status not in ("open", "partially_paid", "paid"): + raise HTTPException(status_code=400, detail="Order is not open") + + # Adding items to a fully-paid order reopens it — partially_paid since prior items were paid + if order.status == "paid": + order.status = "partially_paid" + + new_item_ids = [] + for item_in in body.items: + product = db.query(Product).filter(Product.id == item_in.product_id).first() + if not product or not product.is_available: + raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available") + extra_cost = sum( + (o.price_delta or o.extra_cost or 0.0) + for o in (item_in.selected_options or []) + ) + item = OrderItem( + order_id=order_id, + product_id=item_in.product_id, + added_by=user.id, + quantity=item_in.quantity, + unit_price=product.base_price + extra_cost, + selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None, + removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None, + notes=item_in.notes, + ) + db.add(item) + db.flush() + new_item_ids.append(item.id) + + _audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids) + db.commit() + db.refresh(order) + + print_results = route_and_print_sync(order_id, new_item_ids, db) + broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "items_added", "item_ids": new_item_ids}) + return {"order": order, "print_results": print_results} + + +@router.post("/{order_id}/retry-print", response_model=AddItemsResponse) +def retry_print( + order_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + + unprinted_ids = [item.id for item in order.items if not item.printed and item.status == "active"] + if not unprinted_ids: + return {"order": order, "print_results": []} + + print_results = route_and_print_sync(order_id, unprinted_ids, db) + db.refresh(order) + return {"order": order, "print_results": print_results} + + +@router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut) +def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Session = Depends(get_db), user: User = Depends(require_manager)): + item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first() + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if notes is not None: + item.notes = notes + db.commit() + db.refresh(item) + return item + + +@router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first() + if not item: + raise HTTPException(status_code=404, detail="Item not found") + item.status = "cancelled" + _audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id]) + db.commit() + + +@router.post("/{order_id}/pay") +def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + + from models.shift import WaiterShift + + items = db.query(OrderItem).filter( + OrderItem.id.in_(body.item_ids), + OrderItem.order_id == order_id, + OrderItem.status == "active", + ).all() + now = datetime.now(timezone.utc) + active_shift = db.query(WaiterShift).filter( + WaiterShift.waiter_id == user.id, + WaiterShift.ended_at == None, + ).first() + total_paid = 0.0 + for item in items: + item.status = "paid" + item.paid_by = user.id + item.paid_at = now + item.payment_method = body.payment_method + item.paid_in_shift_id = active_shift.id if active_shift else None + total_paid += item.unit_price * item.quantity + + db.flush() # write item status changes before counting, since autoflush=False + active_remaining = db.query(OrderItem).filter( + OrderItem.order_id == order_id, OrderItem.status == "active" + ).count() + order.status = "paid" if active_remaining == 0 else "partially_paid" + + paid_ids = [i.id for i in items] + _audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids, + amount=total_paid, payment_method=body.payment_method) + db.commit() + broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method}) + return {"status": order.status, "paid_item_ids": paid_ids} + + +@router.post("/{order_id}/close") +def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + if order.status not in ("paid", "open", "partially_paid"): + raise HTTPException(status_code=400, detail="Cannot close order in current status") + order.status = "closed" + order.closed_at = datetime.now(timezone.utc) + order.closed_by = user.id + _audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id) + db.commit() + broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id}) + return {"status": "closed"} + + +@router.post("/{order_id}/pay-offline") +def pay_items_offline( + order_id: int, + body: OfflinePaymentRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Sync an emergency payment that was taken while the server was offline. + The UUID prevents double-processing. If a payment with the same UUID already + exists on this order, the duplicate is logged in red (is_duplicate=1) rather + than silently dropped — so managers can reconcile. + """ + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + + # Check for duplicate UUID on this order + existing_uuid = db.query(OrderAuditLog).filter( + OrderAuditLog.order_id == order_id, + OrderAuditLog.offline_uuid == body.uuid, + ).first() + is_duplicate = existing_uuid is not None + + from models.shift import WaiterShift + items = db.query(OrderItem).filter( + OrderItem.id.in_(body.item_ids), + OrderItem.order_id == order_id, + OrderItem.status == "active", + ).all() + + # Reject empty payments — client had no offline snapshot for this table + if not items and not is_duplicate: + raise HTTPException(status_code=400, detail="No active items found — payment rejected") + + # Use the client-recorded offline timestamp as paid_at so audit reflects real payment time + try: + paid_at = datetime.fromisoformat(body.offline_at.replace("Z", "+00:00")) if body.offline_at else datetime.now(timezone.utc) + except (ValueError, AttributeError): + paid_at = datetime.now(timezone.utc) + + active_shift = db.query(WaiterShift).filter( + WaiterShift.waiter_id == user.id, + WaiterShift.ended_at == None, + ).first() + + total_paid = 0.0 + paid_ids = [] + if not is_duplicate: + for item in items: + item.status = "paid" + item.paid_by = user.id + item.paid_at = paid_at + item.payment_method = body.payment_method + item.paid_in_shift_id = active_shift.id if active_shift else None + total_paid += item.unit_price * item.quantity + paid_ids.append(item.id) + + db.flush() + active_remaining = db.query(OrderItem).filter( + OrderItem.order_id == order_id, OrderItem.status == "active" + ).count() + order.status = "paid" if active_remaining == 0 else "partially_paid" + else: + # Duplicate — compute total for audit record without changing item state + total_paid = sum(i.unit_price * i.quantity for i in items) + paid_ids = [i.id for i in items] + + # Always write audit log — duplicate flag makes it visible in red in manager dashboard + db.add(OrderAuditLog( + order_id=order_id, + event_type="PAYMENT_OFFLINE", + waiter_id=user.id, + item_ids=json.dumps(paid_ids), + amount=total_paid, + payment_method=body.payment_method, + note=f"Emergency offline payment (uuid={body.uuid}){' — DUPLICATE' if is_duplicate else ''}", + offline_uuid=body.uuid, + offline_at=body.offline_at, + is_duplicate=1 if is_duplicate else 0, + )) + db.commit() + + if not is_duplicate: + broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method}) + + return { + "status": order.status if not is_duplicate else "duplicate", + "paid_item_ids": paid_ids, + "is_duplicate": is_duplicate, + } + + +@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT) +def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + order.status = "cancelled" + order.closed_at = datetime.now(timezone.utc) + order.closed_by = user.id + _audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id) + db.commit() + broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id}) + + +@router.put("/{order_id}/assign-waiter") +def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + existing = db.query(OrderWaiter).filter( + OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == body.waiter_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="Waiter already assigned") + db.add(OrderWaiter(order_id=order_id, waiter_id=body.waiter_id)) + db.commit() + return {"status": "assigned"} + + +@router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + assignment = db.query(OrderWaiter).filter( + OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == waiter_id + ).first() + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + db.delete(assignment) + db.commit() + + +@router.post("/{order_id}/print") +def print_order( + order_id: int, + body: PrintOrderRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + from models.printer import Printer + + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found or inactive") + + table = db.query(Table).filter(Table.id == order.table_id).first() + table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}" + + opener = db.query(User).filter(User.id == order.opened_by).first() + waiter_name = opener.username if opener else f"#{order.opened_by}" + + items_data = [] + for item in order.items: + if item.status == "cancelled": + continue + product_name = item.product.name if item.product else f"#{item.product_id}" + items_data.append({ + "name": product_name, + "quantity": item.quantity, + "unit_price": item.unit_price, + "total": item.unit_price * item.quantity, + "status": item.status, + }) + + grand_total = sum(i["total"] for i in items_data) + + receipt = { + "order_id": order.id, + "table_name": table_name, + "waiter_name": waiter_name, + "opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"), + "closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None, + "status": order.status, + "items": items_data, + "total": grand_total, + "notes": order.notes, + } + + background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt) + return {"status": "printing"} + + +# ─── Transfer order to a different table ───────────────────────────────────── + +@router.post("/{order_id}/transfer") +def transfer_order( + order_id: int, + body: TransferOrderRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + if order.status not in ("open", "partially_paid", "paid"): + raise HTTPException(status_code=400, detail="Order is not active") + + target_table = db.query(Table).filter(Table.id == body.target_table_id, Table.is_active == True).first() + if not target_table: + raise HTTPException(status_code=404, detail="Target table not found") + if body.target_table_id == order.table_id: + raise HTTPException(status_code=400, detail="Table is already assigned to this order") + + conflict = db.query(Order).filter( + Order.table_id == body.target_table_id, + Order.status.in_(["open", "partially_paid", "paid"]), + ).first() + if conflict: + raise HTTPException(status_code=400, detail="Target table already has an active order") + + old_table_id = order.table_id + order.table_id = body.target_table_id + _audit(db, order_id, "TABLE_TRANSFER", waiter_id=user.id, + note=f"Transferred from table {old_table_id} to table {body.target_table_id}") + db.commit() + db.refresh(order) + broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "old_table_id": old_table_id, "status": order.status, "action": "transferred"}) + return order + + +# ─── Merge another order into this one ─────────────────────────────────────── + +@router.post("/{order_id}/merge") +def merge_order( + order_id: int, + body: MergeOrderRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Merge source order (order_id) INTO target order (body.target_order_id). + All items (paid + active) from the source are reassigned to the target. + Source waiters are added to the target if not already there. + Source order is cancelled with audit note. + """ + source = db.query(Order).filter(Order.id == order_id).first() + if not source: + raise HTTPException(status_code=404, detail="Source order not found") + if not _can_access_order(source, user, db): + raise HTTPException(status_code=403, detail="Access denied") + if source.status not in ("open", "partially_paid", "paid"): + raise HTTPException(status_code=400, detail="Source order is not active") + + target = db.query(Order).filter(Order.id == body.target_order_id).first() + if not target: + raise HTTPException(status_code=404, detail="Target order not found") + if not _can_access_order(target, user, db): + raise HTTPException(status_code=403, detail="Access denied to target order") + if target.status not in ("open", "partially_paid", "paid"): + raise HTTPException(status_code=400, detail="Target order is not active") + if source.id == target.id: + raise HTTPException(status_code=400, detail="Cannot merge an order with itself") + + # Move all items to target order + moved_item_ids = [] + for item in source.items: + item.order_id = target.id + moved_item_ids.append(item.id) + + # Copy source waiters to target (no duplicates) + existing_waiter_ids = {w.waiter_id for w in target.waiters} + for ow in source.waiters: + if ow.waiter_id not in existing_waiter_ids: + db.add(OrderWaiter(order_id=target.id, waiter_id=ow.waiter_id)) + + # Recompute target status after flush + db.flush() + active_remaining = db.query(OrderItem).filter( + OrderItem.order_id == target.id, OrderItem.status == "active" + ).count() + paid_exists = db.query(OrderItem).filter( + OrderItem.order_id == target.id, OrderItem.status == "paid" + ).count() + if active_remaining > 0: + target.status = "partially_paid" if paid_exists > 0 else "open" + else: + target.status = "paid" + + # Cancel source order + source.status = "cancelled" + source.closed_at = datetime.now(timezone.utc) + source.closed_by = user.id + + _audit(db, source.id, "ORDER_CANCELLED", waiter_id=user.id, + note=f"Merged into order #{target.id} (table {target.table_id})") + _audit(db, target.id, "ITEMS_ADDED", waiter_id=user.id, item_ids=moved_item_ids, + note=f"Items merged from order #{source.id} (table {source.table_id})") + + db.commit() + db.refresh(target) + broadcast_sync("order_updated", {"order_id": target.id, "table_id": target.table_id, "status": target.status, "action": "merged"}) + broadcast_sync("order_closed", {"order_id": source.id, "table_id": source.table_id}) + return target + + +# ─── Split a stacked item into two rows ────────────────────────────────────── + +@router.post("/{order_id}/items/{item_id}/split", response_model=List[OrderItemOut]) +def split_item( + order_id: int, + item_id: int, + body: SplitItemRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + Split qty units off item_id into a new item row. + Both rows share all properties (product, price, options, notes). + Only active items can be split. + """ + item = db.query(OrderItem).filter( + OrderItem.id == item_id, OrderItem.order_id == order_id + ).first() + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if item.status != "active": + raise HTTPException(status_code=400, detail="Only active items can be split") + if body.quantity <= 0 or body.quantity >= item.quantity: + raise HTTPException( + status_code=400, + detail=f"Split quantity must be between 1 and {item.quantity - 1}" + ) + + order = db.query(Order).filter(Order.id == order_id).first() + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + + # Reduce original item + item.quantity -= body.quantity + + # Create split-off item + new_item = OrderItem( + order_id=order_id, + product_id=item.product_id, + added_by=item.added_by, + quantity=body.quantity, + unit_price=item.unit_price, + selected_options=item.selected_options, + removed_ingredients=item.removed_ingredients, + notes=item.notes, + status="active", + printed=item.printed, + ) + db.add(new_item) + db.commit() + db.refresh(item) + db.refresh(new_item) + return [item, new_item] + + +# ─── Move selected items to another order ──────────────────────────────────── + +@router.post("/{order_id}/move-items") +def move_items( + order_id: int, + body: MoveItemsRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Move specific active items from this order to another open order.""" + source = db.query(Order).filter(Order.id == order_id).first() + if not source: + raise HTTPException(status_code=404, detail="Source order not found") + if not _can_access_order(source, user, db): + raise HTTPException(status_code=403, detail="Access denied") + if source.status not in ("open", "partially_paid"): + raise HTTPException(status_code=400, detail="Source order is not active") + + target = db.query(Order).filter(Order.id == body.target_order_id).first() + if not target: + raise HTTPException(status_code=404, detail="Target order not found") + if not _can_access_order(target, user, db): + raise HTTPException(status_code=403, detail="Access denied to target order") + if target.status not in ("open", "partially_paid"): + raise HTTPException(status_code=400, detail="Target order is not active") + if source.id == target.id: + raise HTTPException(status_code=400, detail="Source and target orders are the same") + + items = db.query(OrderItem).filter( + OrderItem.id.in_(body.item_ids), + OrderItem.order_id == order_id, + OrderItem.status == "active", + ).all() + if not items: + raise HTTPException(status_code=400, detail="No active items found to move") + + moved_ids = [] + for item in items: + item.order_id = target.id + moved_ids.append(item.id) + + # Recompute source status + db.flush() + src_active = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "active").count() + src_paid = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "paid").count() + if src_active == 0 and src_paid == 0: + source.status = "open" + elif src_active == 0: + source.status = "paid" + else: + source.status = "partially_paid" if src_paid > 0 else "open" + + # Recompute target status + tgt_active = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "active").count() + tgt_paid = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "paid").count() + target.status = "partially_paid" if (tgt_active > 0 and tgt_paid > 0) else ("paid" if tgt_active == 0 else "open") + + _audit(db, source.id, "ITEMS_MOVED_OUT", waiter_id=user.id, item_ids=moved_ids, + note=f"Moved to order #{target.id} (table {target.table_id})") + _audit(db, target.id, "ITEMS_MOVED_IN", waiter_id=user.id, item_ids=moved_ids, + note=f"Moved from order #{source.id} (table {source.table_id})") + + db.commit() + db.refresh(source) + return {"moved_item_ids": moved_ids, "source_status": source.status, "target_status": target.status} + + +# ─── Print order synopsis ───────────────────────────────────────────────────── + +@router.post("/{order_id}/print-synopsis") +def print_synopsis( + order_id: int, + body: PrintSynopsisRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + from models.printer import Printer + + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if not _can_access_order(order, user, db): + raise HTTPException(status_code=403, detail="Access denied") + + printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found or inactive") + + table = db.query(Table).filter(Table.id == order.table_id).first() + table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}" + opener = db.query(User).filter(User.id == order.opened_by).first() + waiter_name = (opener.nickname or opener.username) if opener else f"#{order.opened_by}" + + items_data = [] + for item in order.items: + if item.status == "cancelled": + continue + product_name = item.product.name if item.product else f"#{item.product_id}" + items_data.append({ + "name": product_name, + "quantity": item.quantity, + "unit_price": item.unit_price, + "total": item.unit_price * item.quantity, + "status": item.status, + }) + + total = sum(i["total"] for i in items_data) + paid_total = sum(i["total"] for i in items_data if i["status"] == "paid") + + synopsis = { + "order_id": order.id, + "table_name": table_name, + "waiter_name": waiter_name, + "opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"), + "items": items_data, + "total": total, + "paid_total": paid_total, + "remaining": total - paid_total, + } + + background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis) + return {"status": "printing"} diff --git a/local_backend/routers/products.py b/local_backend/routers/products.py new file mode 100644 index 0000000..7db4c88 --- /dev/null +++ b/local_backend/routers/products.py @@ -0,0 +1,331 @@ +import os +import uuid +import json +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from sqlalchemy.orm import Session +from typing import List + +from database import get_db +from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice +from models.order import OrderItem +from models.user import User +from schemas.product import ( + ProductCreate, ProductUpdate, ProductOut, ProductReorderItem, + CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem, + SubcategoryReorderItem, ParentGeneralReorderItem, + PreferenceSetCreate, ProductQuickOptionCreate, + CategoryReparentRequest, +) +from routers.deps import get_current_user, require_manager + +router = APIRouter() + +IMAGE_DIR = "/app/data/product_images" + + +def _replace_quick_options(db, product, quick_options): + for qo in product.quick_options: + db.delete(qo) + db.flush() + for i, qo in enumerate(quick_options): + db.add(ProductQuickOption( + product_id=product.id, + name=qo.name, + price=qo.price, + allow_multiple=qo.allow_multiple, + sort_order=qo.sort_order if qo.sort_order else i, + is_favorite=qo.is_favorite, + favorite_sort_order=qo.favorite_sort_order, + is_compact=qo.is_compact, + )) + + +def _replace_options(db, product, options): + for opt in product.options: + db.delete(opt) + db.flush() + for opt in options: + sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None + db.add(ProductOption( + product_id=product.id, + name=opt.name, + extra_cost=opt.extra_cost, + allow_multiple=opt.allow_multiple, + sub_choices=sub_json, + is_favorite=opt.is_favorite, + favorite_sort_order=opt.favorite_sort_order, + )) + + +def _replace_ingredients(db, product, ingredients): + for ing in product.ingredients: + db.delete(ing) + db.flush() + for ing in ingredients: + db.add(ProductIngredient(product_id=product.id, **ing.model_dump())) + + +def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]): + for ps in product.preference_sets: + db.delete(ps) + db.flush() + for ps in sets: + shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None + new_set = ProductPreferenceSet( + product_id=product.id, + name=ps.name, + shared_subset=shared_json, + is_favorite=ps.is_favorite, + favorite_sort_order=ps.favorite_sort_order, + ) + db.add(new_set) + db.flush() + created_choices = [] + for ch in ps.choices: + sub_json = json.dumps([s.model_dump() for s in ch.sub_choices]) if ch.sub_choices else None + choice = ProductPreferenceChoice( + set_id=new_set.id, + name=ch.name, + extra_cost=ch.extra_cost, + sub_choices=sub_json, + disables_subset=ch.disables_subset, + ) + db.add(choice) + db.flush() + created_choices.append(choice) + if ps.default_choice_index is not None and 0 <= ps.default_choice_index < len(created_choices): + new_set.default_choice_id = created_choices[ps.default_choice_index].id + + +# ── Categories ──────────────────────────────────────────────────────────────── + +@router.get("/categories", response_model=List[CategoryOut]) +def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + return db.query(Category).order_by(Category.sort_order).all() + + +@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED) +def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + # sort_order is among siblings (same parent_id level) + sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count() + cat = Category( + name=body.name, + color=body.color, + sort_order=sibling_count, + parent_id=body.parent_id, + general_sort_order=body.general_sort_order, + ) + db.add(cat) + db.commit() + db.refresh(cat) + return cat + + +@router.put("/categories/reorder", status_code=status.HTTP_204_NO_CONTENT) +def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)): + for item in items: + cat = db.query(Category).filter(Category.id == item.id).first() + if cat: + cat.sort_order = item.sort_order + db.commit() + + +@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT) +def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)): + """Reorder sub-categories within their parent (sort_order among siblings).""" + for item in items: + cat = db.query(Category).filter(Category.id == item.id).first() + if cat: + cat.sort_order = item.sort_order + db.commit() + + +@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT) +def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)): + """Update general_sort_order on parent categories (position of the General group).""" + for item in items: + cat = db.query(Category).filter(Category.id == item.id).first() + if cat: + cat.general_sort_order = item.general_sort_order + db.commit() + + +@router.put("/categories/{category_id}/reparent", response_model=CategoryOut) +def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)): + """Move a category to a new parent (or promote to top-level if parent_id is null). + All products assigned to this category follow it automatically (no product updates needed). + """ + cat = db.query(Category).filter(Category.id == category_id).first() + if not cat: + raise HTTPException(status_code=404, detail="Category not found") + if body.parent_id is not None: + new_parent = db.query(Category).filter(Category.id == body.parent_id).first() + if not new_parent: + raise HTTPException(status_code=404, detail="Target parent category not found") + if new_parent.parent_id is not None: + raise HTTPException(status_code=400, detail="Cannot nest more than two levels deep") + if body.parent_id == category_id: + raise HTTPException(status_code=400, detail="A category cannot be its own parent") + # If cat currently has children and is being made a sub, block it + has_children = db.query(Category).filter(Category.parent_id == category_id).count() > 0 + if has_children and body.parent_id is not None: + raise HTTPException(status_code=400, detail="Cannot nest a category that has subcategories") + # Assign new sort_order at the end of the destination level + sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count() + cat.parent_id = body.parent_id + cat.sort_order = sibling_count + db.commit() + db.refresh(cat) + return cat + + +@router.put("/categories/{category_id}", response_model=CategoryOut) +def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + cat = db.query(Category).filter(Category.id == category_id).first() + if not cat: + raise HTTPException(status_code=404, detail="Category not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(cat, field, value) + db.commit() + db.refresh(cat) + return cat + + +@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + cat = db.query(Category).filter(Category.id == category_id).first() + if not cat: + raise HTTPException(status_code=404, detail="Category not found") + db.delete(cat) + db.commit() + + +# ── Products ────────────────────────────────────────────────────────────────── + +@router.get("/", response_model=List[ProductOut]) +def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + q = db.query(Product) + if not all or user.role not in ("manager", "sysadmin"): + # Waiters only see active, available products + q = q.filter(Product.is_available == True, Product.lifecycle_status == "active") + return q.order_by(Product.sort_order, Product.id).all() + + +@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT) +def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)): + for item in items: + product = db.query(Product).filter(Product.id == item.id).first() + if product: + product.sort_order = item.sort_order + db.commit() + + +@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED) +def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets"}) + if data.get("sort_order") == 0: + data["sort_order"] = db.query(Product).count() + product = Product(**data) + db.add(product) + db.flush() + for i, qo in enumerate(body.quick_options): + db.add(ProductQuickOption( + product_id=product.id, + name=qo.name, + price=qo.price, + allow_multiple=qo.allow_multiple, + sort_order=qo.sort_order if qo.sort_order else i, + is_favorite=qo.is_favorite, + favorite_sort_order=qo.favorite_sort_order, + is_compact=qo.is_compact, + )) + for opt in body.options: + sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None + db.add(ProductOption( + product_id=product.id, + name=opt.name, + extra_cost=opt.extra_cost, + allow_multiple=opt.allow_multiple, + sub_choices=sub_json, + is_favorite=opt.is_favorite, + favorite_sort_order=opt.favorite_sort_order, + )) + for ing in body.ingredients: + db.add(ProductIngredient(product_id=product.id, **ing.model_dump())) + _replace_preference_sets(db, product, body.preference_sets) + db.commit() + db.refresh(product) + return product + + +@router.put("/{product_id}", response_model=ProductOut) +def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException(status_code=404, detail="Product not found") + for field, value in body.model_dump(exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items(): + setattr(product, field, value) + if body.quick_options is not None: + _replace_quick_options(db, product, body.quick_options) + if body.options is not None: + _replace_options(db, product, body.options) + if body.ingredients is not None: + _replace_ingredients(db, product, body.ingredients) + if body.preference_sets is not None: + _replace_preference_sets(db, product, body.preference_sets) + db.commit() + db.refresh(product) + return product + + +@router.post("/{product_id}/image", response_model=ProductOut) +async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)): + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="File must be an image") + + os.makedirs(IMAGE_DIR, exist_ok=True) + + if product.image_url: + old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url)) + if os.path.exists(old_path): + os.remove(old_path) + + ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "jpg" + filename = f"{product_id}_{uuid.uuid4().hex[:8]}.{ext}" + filepath = os.path.join(IMAGE_DIR, filename) + + contents = await file.read() + with open(filepath, "wb") as f: + f.write(contents) + + product.image_url = f"/static/product_images/{filename}" + db.commit() + db.refresh(product) + return product + + +@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)): + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException(status_code=404, detail="Product not found") + if hard: + has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first() + if has_orders: + raise HTTPException( + status_code=400, + detail="Cannot permanently delete a product that appears in past orders. Archive it instead." + ) + db.delete(product) + else: + # If product has order history, archive it; otherwise hard delete + has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first() + if has_orders: + product.lifecycle_status = "archived" + else: + db.delete(product) + db.commit() diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py new file mode 100644 index 0000000..c94bce5 --- /dev/null +++ b/local_backend/routers/reports.py @@ -0,0 +1,1160 @@ +import csv +import io +import json + +from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import date, datetime, timedelta +from typing import Optional, List + +from database import get_db +from models.order import Order, OrderItem, OrderWaiter, PrintLog +from models.user import User +from models.table import Table +from models.printer import Printer +from models.shift import WaiterShift +from models.business_day import BusinessDay +from schemas.order import OrderOut +from schemas.table import TableOut +from routers.deps import require_manager +from services.printer_service import print_waiter_report, print_printer_report, print_order_receipt + +router = APIRouter() + + +def _dt(dt): + if dt is None: + return None + return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat() + + +@router.get("/shift") +def shift_summary( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + report_date: Optional[date] = Query(default=None, alias="date"), + waiter_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + """Payments collected per waiter — based on paid_by on order items.""" + if from_dt and to_dt: + start = datetime.fromisoformat(from_dt) + end = datetime.fromisoformat(to_dt) + else: + target = report_date or date.today() + start = datetime.combine(target, datetime.min.time()) + end = start + timedelta(days=1) + + q = db.query(OrderItem).filter( + OrderItem.status == "paid", + OrderItem.paid_at >= start, + OrderItem.paid_at < end, + ) + if waiter_id: + q = q.filter(OrderItem.paid_by == waiter_id) + items = q.all() + + waiters_db = {u.id: u for u in db.query(User).all()} + tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + + # Build per-waiter summary keyed by waiter_id + summary: dict[int, dict] = {} + for item in items: + wid = item.paid_by + if wid not in summary: + w = waiters_db.get(wid) + wname = (w.full_name or w.username) if w else f"#{wid}" + summary[wid] = { + "waiter_id": wid, + "waiter_name": wname, + "items": 0, + "total": 0.0, + "order_data": {}, + } + summary[wid]["items"] += item.quantity + val = item.unit_price * item.quantity + summary[wid]["total"] += val + + oid = item.order_id + if oid not in summary[wid]["order_data"]: + order = db.query(Order).filter(Order.id == oid).first() + summary[wid]["order_data"][oid] = { + "id": oid, + "time_open": order.opened_at.strftime("%H:%M") if order else "", + "time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "", + "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", + "total": 0.0, + "items": [], + } + summary[wid]["order_data"][oid]["total"] += val + product_name = item.product.name if item.product else f"#{item.product_id}" + summary[wid]["order_data"][oid]["items"].append( + {"name": product_name, "quantity": item.quantity} + ) + + result = [] + for entry in summary.values(): + entry["orders"] = len(entry["order_data"]) + entry["order_data"] = list(entry["order_data"].values()) + result.append(entry) + + return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result} + + +@router.get("/shift/orders") +def shift_orders_summary( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + report_date: Optional[date] = Query(default=None, alias="date"), + waiter_id: Optional[int] = None, + business_day_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + """Items sent (added) per waiter — regardless of payment status.""" + q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"])) + if business_day_id: + q = q.join(Order).filter(Order.business_day_id == business_day_id) + else: + if from_dt and to_dt: + start = datetime.fromisoformat(from_dt) + end = datetime.fromisoformat(to_dt) + else: + target = report_date or date.today() + start = datetime.combine(target, datetime.min.time()) + end = start + timedelta(days=1) + q = q.filter(OrderItem.added_at >= start, OrderItem.added_at < end) + if waiter_id: + q = q.filter(OrderItem.added_by == waiter_id) + items = q.all() + + waiters_db = {u.id: u for u in db.query(User).all()} + tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + + summary: dict[int, dict] = {} + for item in items: + wid = item.added_by + if wid not in summary: + w = waiters_db.get(wid) + wname = (w.full_name or w.username) if w else f"#{wid}" + summary[wid] = { + "waiter_id": wid, + "waiter_name": wname, + "items": 0, + "total": 0.0, + "order_data": {}, + } + summary[wid]["items"] += item.quantity + val = item.unit_price * item.quantity + summary[wid]["total"] += val + + oid = item.order_id + if oid not in summary[wid]["order_data"]: + order = db.query(Order).filter(Order.id == oid).first() + summary[wid]["order_data"][oid] = { + "id": oid, + "time_open": order.opened_at.strftime("%H:%M") if order else "", + "time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "", + "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", + "total": 0.0, + "items": [], + } + summary[wid]["order_data"][oid]["total"] += val + product_name = item.product.name if item.product else f"#{item.product_id}" + summary[wid]["order_data"][oid]["items"].append( + {"name": product_name, "quantity": item.quantity} + ) + + result = [] + for entry in summary.values(): + entry["orders"] = len(entry["order_data"]) + entry["order_data"] = list(entry["order_data"].values()) + result.append(entry) + + return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result} + + +@router.get("/orders/history", response_model=List[OrderOut]) +def order_history( + from_date: Optional[str] = Query(default=None, alias="from"), + to_date: Optional[str] = Query(default=None, alias="to"), + waiter_id: Optional[int] = None, + order_status: Optional[str] = Query(default=None, alias="status"), + table_id: Optional[int] = None, + business_day_id: Optional[int] = None, + page: int = 1, + page_size: int = 50, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + q = db.query(Order) + if business_day_id: + q = q.filter(Order.business_day_id == business_day_id) + elif from_date or to_date: + if from_date: + q = q.filter(Order.opened_at >= datetime.fromisoformat(from_date)) + if to_date: + q = q.filter(Order.opened_at <= datetime.fromisoformat(to_date)) + if waiter_id: + q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id) + if order_status: + q = q.filter(Order.status == order_status) + if table_id: + q = q.filter(Order.table_id == table_id) + return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + +@router.get("/tables/summary") +def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_manager)): + tables = db.query(Table).filter(Table.is_active == True).all() + result = [] + for table in tables: + active_order = db.query(Order).filter( + Order.table_id == table.id, + Order.status.in_(["open", "partially_paid"]), + ).first() + result.append({ + "table": TableOut.model_validate(table), + "status": active_order.status if active_order else "free", + "order_id": active_order.id if active_order else None, + }) + return result + + +@router.get("/printers") +def printer_totals( + from_date: Optional[str] = Query(default=None, alias="from"), + to_date: Optional[str] = Query(default=None, alias="to"), + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + """Returns totals per printer based on print_log entries in the date range.""" + q = db.query(PrintLog).filter(PrintLog.success == True) + if from_date: + q = q.filter(PrintLog.printed_at >= datetime.fromisoformat(from_date)) + if to_date: + q = q.filter(PrintLog.printed_at <= datetime.fromisoformat(to_date)) + logs = q.all() + + printers_db = {p.id: p for p in db.query(Printer).all()} + tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + + # summary[pid] — aggregated totals + summary: dict[int, dict] = {} + # order_map[pid][order_id] — per-order detail with items + order_map: dict[int, dict] = {} + + for log in logs: + pid = log.printer_id + if pid not in summary: + printer = printers_db.get(pid) + summary[pid] = { + "printer_id": pid, + "printer_name": printer.name if printer else f"Printer #{pid}", + "print_jobs": 0, + "orders": set(), + "items": 0, + "total": 0.0, + } + order_map[pid] = {} + + summary[pid]["print_jobs"] += 1 + summary[pid]["orders"].add(log.order_id) + + oid = log.order_id + if oid not in order_map[pid]: + order = db.query(Order).filter(Order.id == oid).first() + order_map[pid][oid] = { + "order_id": oid, + "time": log.printed_at.strftime("%H:%M"), + "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", + "total": 0.0, + "items": [], + } + + try: + item_ids = json.loads(log.item_ids) + except Exception: + item_ids = [] + for item_id in item_ids: + item = db.query(OrderItem).filter(OrderItem.id == item_id).first() + if item and item.status in ("active", "paid"): + summary[pid]["items"] += item.quantity + val = item.unit_price * item.quantity + summary[pid]["total"] += val + order_map[pid][oid]["total"] += val + product_name = item.product.name if item.product else f"#{item.product_id}" + order_map[pid][oid]["items"].append({"name": product_name, "quantity": item.quantity}) + + result = [] + for pid, entry in summary.items(): + entry["orders"] = len(entry["orders"]) + entry["order_data"] = list(order_map.get(pid, {}).values()) + result.append(entry) + return {"printers": result} + + +class PrintWaiterReportBody(BaseModel): + waiter_name: str + printer_id: int + mode: str # "simple" | "extensive" + from_dt: str + to_dt: str + + +class PrintPrinterReportBody(BaseModel): + printer_target_id: int + printer_id: int + mode: str # "simple" | "extensive" + from_dt: str + to_dt: str + + +class PrintOrderBody(BaseModel): + printer_id: int + + +@router.post("/print/waiter") +def print_waiter( + body: PrintWaiterReportBody, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found or inactive") + + from_dt = datetime.fromisoformat(body.from_dt) + to_dt = datetime.fromisoformat(body.to_dt) + + # Gather orders for this waiter in time range + waiter = db.query(User).filter(User.username == body.waiter_name).first() + q = db.query(Order).filter( + Order.opened_at >= from_dt, + Order.opened_at <= to_dt, + ) + if waiter: + q = q.filter(Order.opened_by == waiter.id) + else: + q = q.filter(False) + orders = q.all() + + # Enrich with table names + tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + order_data = [] + for o in orders: + active_items = [i for i in o.items if i.status in ("active", "paid")] + total = sum(i.unit_price * i.quantity for i in active_items) + order_data.append({ + "id": o.id, + "time_open": o.opened_at.strftime("%H:%M"), + "time_close": o.closed_at.strftime("%H:%M") if o.closed_at else "", + "table": tables.get(o.table_id, f"#{o.table_id}"), + "total": total, + "items": [ + {"name": (i.product.name if i.product else f"#{i.product_id}"), "quantity": i.quantity} + for i in active_items + ], + }) + + items_count = sum( + i.quantity for o in orders for i in o.items if i.status in ("active", "paid") + ) + grand_total = sum(d["total"] for d in order_data) + + report = { + "waiter_name": body.waiter_name, + "orders": len(orders), + "items": items_count, + "total": grand_total, + "order_data": order_data if body.mode == "extensive" else [], + "from_dt": from_dt.strftime("%d/%m/%Y %H:%M"), + "to_dt": to_dt.strftime("%d/%m/%Y %H:%M"), + } + + background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode) + return {"status": "printing"} + + +@router.post("/print/printer") +def print_printer_totals( + body: PrintPrinterReportBody, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found or inactive") + + target_printer = db.query(Printer).filter(Printer.id == body.printer_target_id).first() + target_name = target_printer.name if target_printer else f"Printer #{body.printer_target_id}" + + from_dt = datetime.fromisoformat(body.from_dt) + to_dt = datetime.fromisoformat(body.to_dt) + + logs = db.query(PrintLog).filter( + PrintLog.printer_id == body.printer_target_id, + PrintLog.success == True, + PrintLog.printed_at >= from_dt, + PrintLog.printed_at <= to_dt, + ).all() + + tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + + # Build per-order entries keyed by order_id; each log may add more items + order_map: dict = {} + items_count = 0 + grand_total = 0.0 + for log in logs: + oid = log.order_id + if oid not in order_map: + order = db.query(Order).filter(Order.id == oid).first() + if order: + order_map[oid] = { + "id": oid, + "time": log.printed_at.strftime("%H:%M"), + "table": tables.get(order.table_id, f"#{order.table_id}"), + "total": 0.0, + "items": [], + } + try: + item_ids = json.loads(log.item_ids) + except Exception: + item_ids = [] + for item_id in item_ids: + item = db.query(OrderItem).filter(OrderItem.id == item_id).first() + if item and item.status in ("active", "paid"): + items_count += item.quantity + val = item.unit_price * item.quantity + grand_total += val + if oid in order_map: + order_map[oid]["total"] += val + product_name = item.product.name if item.product else f"#{item.product_id}" + order_map[oid]["items"].append({"name": product_name, "quantity": item.quantity}) + + order_data = list(order_map.values()) + + report = { + "printer_name": target_name, + "print_jobs": len(logs), + "orders": len(order_map), + "items": items_count, + "total": grand_total, + "order_data": order_data if body.mode == "extensive" else [], + "from_dt": from_dt.strftime("%d/%m/%Y %H:%M"), + "to_dt": to_dt.strftime("%d/%m/%Y %H:%M"), + } + + background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode) + return {"status": "printing"} + + +# --------------------------------------------------------------------------- +# Shift history report +# --------------------------------------------------------------------------- + +@router.get("/shifts") +def shifts_report( + waiter_id: Optional[int] = None, + business_day_id: Optional[int] = None, + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + active_only: bool = False, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + from routers.shifts import compute_shift_total + + q = db.query(WaiterShift) + if waiter_id: + q = q.filter(WaiterShift.waiter_id == waiter_id) + if business_day_id: + q = q.filter(WaiterShift.business_day_id == business_day_id) + if from_dt: + q = q.filter(WaiterShift.started_at >= datetime.fromisoformat(from_dt)) + if to_dt: + q = q.filter(WaiterShift.started_at <= datetime.fromisoformat(to_dt)) + if active_only: + q = q.filter(WaiterShift.ended_at == None) + + shifts = q.order_by(WaiterShift.started_at.desc()).all() + waiters_db = {u.id: u for u in db.query(User).all()} + + result = [] + for shift in shifts: + w = waiters_db.get(shift.waiter_id) + wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}" + total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0) + result.append({ + "id": shift.id, + "waiter_id": shift.waiter_id, + "waiter_name": wname, + "business_day_id": shift.business_day_id, + "started_at": _dt(shift.started_at), + "ended_at": _dt(shift.ended_at), + "starting_cash": shift.starting_cash, + "total_collected": total, + "net_to_deliver": round(total + (shift.starting_cash or 0.0), 2), + "is_active": shift.ended_at is None, + "notes": shift.notes, + }) + + return {"shifts": result} + + +# --------------------------------------------------------------------------- +# Product performance analytics +# --------------------------------------------------------------------------- + +@router.get("/products/performance") +def product_performance( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + business_day_id: Optional[int] = None, + category_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + from models.product import Product + + q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"])) + if from_dt: + q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt)) + if to_dt: + q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt)) + if business_day_id: + q = q.join(Order).filter(Order.business_day_id == business_day_id) + + items = q.all() + products_db = {p.id: p for p in db.query(Product).all()} + + summary: dict = {} + for item in items: + pid = item.product_id + product = products_db.get(pid) + if category_id and (not product or product.category_id != category_id): + continue + if pid not in summary: + summary[pid] = { + "product_id": pid, + "product_name": product.name if product else f"#{pid}", + "category_id": product.category_id if product else None, + "qty_sold": 0, + "revenue": 0.0, + "order_ids": set(), + } + summary[pid]["qty_sold"] += item.quantity + summary[pid]["revenue"] += item.unit_price * item.quantity + summary[pid]["order_ids"].add(item.order_id) + + result = [] + for entry in summary.values(): + entry["order_count"] = len(entry.pop("order_ids")) + entry["revenue"] = round(entry["revenue"], 2) + result.append(entry) + + result.sort(key=lambda x: x["qty_sold"], reverse=True) + return {"products": result} + + +# --------------------------------------------------------------------------- +# Table performance analytics +# --------------------------------------------------------------------------- + +@router.get("/tables/performance") +def table_performance( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + business_day_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + q = db.query(Order).filter(Order.status.in_(["closed", "paid"])) + if from_dt: + q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt)) + if to_dt: + q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt)) + if business_day_id: + q = q.filter(Order.business_day_id == business_day_id) + orders = q.all() + + tables_db = {t.id: t for t in db.query(Table).all()} + + summary: dict = {} + for order in orders: + tid = order.table_id + if tid not in summary: + t = tables_db.get(tid) + summary[tid] = { + "table_id": tid, + "table_name": (t.label or f"T{t.number}") if t else f"#{tid}", + "order_count": 0, + "revenue": 0.0, + "durations": [], + } + summary[tid]["order_count"] += 1 + summary[tid]["revenue"] += sum( + i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid") + ) + if order.closed_at and order.opened_at: + summary[tid]["durations"].append( + (order.closed_at - order.opened_at).total_seconds() / 60 + ) + + result = [] + for entry in summary.values(): + durations = entry.pop("durations") + entry["avg_duration_minutes"] = round(sum(durations) / len(durations), 1) if durations else None + entry["revenue"] = round(entry["revenue"], 2) + result.append(entry) + + result.sort(key=lambda x: x["revenue"], reverse=True) + return {"tables": result} + + +# --------------------------------------------------------------------------- +# Traffic analysis (hour-of-day / day-of-week) +# --------------------------------------------------------------------------- + +@router.get("/traffic") +def traffic_analysis( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + business_day_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + q = db.query(Order) + if from_dt: + q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt)) + if to_dt: + q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt)) + if business_day_id: + q = q.filter(Order.business_day_id == business_day_id) + orders = q.all() + + by_hour = {h: {"hour": h, "orders": 0, "revenue": 0.0} for h in range(24)} + day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + by_weekday = {d: {"day": d, "label": day_labels[d], "orders": 0, "revenue": 0.0} for d in range(7)} + + for order in orders: + revenue = sum( + i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid") + ) + h = order.opened_at.hour + d = order.opened_at.weekday() + by_hour[h]["orders"] += 1 + by_hour[h]["revenue"] += revenue + by_weekday[d]["orders"] += 1 + by_weekday[d]["revenue"] += revenue + + for h in by_hour: + by_hour[h]["revenue"] = round(by_hour[h]["revenue"], 2) + for d in by_weekday: + by_weekday[d]["revenue"] = round(by_weekday[d]["revenue"], 2) + + return { + "by_hour": list(by_hour.values()), + "by_weekday": list(by_weekday.values()), + } + + +# --------------------------------------------------------------------------- +# Business days list +# --------------------------------------------------------------------------- + +@router.get("/business-days") +def business_days_list( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + q = db.query(BusinessDay) + if from_dt: + q = q.filter(BusinessDay.opened_at >= datetime.fromisoformat(from_dt)) + if to_dt: + q = q.filter(BusinessDay.opened_at <= datetime.fromisoformat(to_dt)) + days = q.order_by(BusinessDay.opened_at.desc()).all() + + waiters_db = {u.id: u for u in db.query(User).all()} + result = [] + for d in days: + orders = db.query(Order).filter(Order.business_day_id == d.id).all() + closed_orders = [o for o in orders if o.status in ("closed", "paid")] + cancelled_orders = [o for o in orders if o.status == "cancelled"] + waiter_ids = set() + for s in (db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()): + waiter_ids.add(s.waiter_id) + revenue = sum( + sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")) + for o in closed_orders + ) + opener = waiters_db.get(d.opened_by_id) + closer = waiters_db.get(d.closed_by_id) if d.closed_by_id else None + result.append({ + "id": d.id, + "status": d.status, + "opened_at": _dt(d.opened_at), + "closed_at": _dt(d.closed_at), + "opened_by": (opener.full_name or opener.username) if opener else None, + "closed_by": (closer.full_name or closer.username) if closer else None, + "notes": d.notes, + "order_count": len(orders), + "closed_order_count": len(closed_orders), + "cancellation_count": len(cancelled_orders), + "waiter_count": len(waiter_ids), + "revenue": round(revenue, 2), + }) + return {"business_days": result} + + +@router.get("/business-days/current") +def current_business_day( + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + day = db.query(BusinessDay).filter(BusinessDay.status == "open").order_by(BusinessDay.opened_at.desc()).first() + if not day: + return {"business_day": None} + + orders = db.query(Order).filter(Order.business_day_id == day.id).all() + closed_orders = [o for o in orders if o.status in ("closed", "paid")] + open_orders = [o for o in orders if o.status in ("open", "partially_paid")] + cancelled_orders = [o for o in orders if o.status == "cancelled"] + active_shifts = db.query(WaiterShift).filter( + WaiterShift.business_day_id == day.id, + WaiterShift.ended_at == None, + ).all() + revenue = sum( + sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")) + for o in closed_orders + ) + + item_counts: dict = {} + for o in orders: + if o.status == "cancelled": + continue + for item in o.items: + if item.status in ("active", "paid"): + pid = item.product_id + item_counts[pid] = item_counts.get(pid, 0) + item.quantity + top_product_id = max(item_counts, key=item_counts.get) if item_counts else None + top_product = None + if top_product_id: + from models.product import Product + p = db.query(Product).filter(Product.id == top_product_id).first() + top_product = {"id": top_product_id, "name": p.name if p else f"#{top_product_id}", "qty": item_counts[top_product_id]} + + return { + "business_day": { + "id": day.id, + "opened_at": _dt(day.opened_at), + "revenue": round(revenue, 2), + "orders_closed": len(closed_orders), + "orders_open": len(open_orders), + "active_waiters": len(active_shifts), + "cancellations": len(cancelled_orders), + "top_product": top_product, + } + } + + +# --------------------------------------------------------------------------- +# Revenue trends +# --------------------------------------------------------------------------- + +@router.get("/revenue/trends") +def revenue_trends( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + granularity: str = Query(default="daily"), # daily | weekly | monthly + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + q = db.query(Order).filter(Order.status.in_(["closed", "paid"])) + if from_dt: + q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt)) + if to_dt: + q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt)) + orders = q.all() + + buckets: dict = {} + for order in orders: + dt = order.opened_at + if granularity == "monthly": + key = dt.strftime("%Y-%m") + elif granularity == "weekly": + monday = dt - timedelta(days=dt.weekday()) + key = monday.strftime("%Y-%m-%d") + else: + key = dt.strftime("%Y-%m-%d") + + if key not in buckets: + buckets[key] = {"date": key, "revenue": 0.0, "orders": 0} + rev = sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")) + buckets[key]["revenue"] += rev + buckets[key]["orders"] += 1 + + arr = sorted(buckets.values(), key=lambda x: x["date"]) + for d in arr: + d["revenue"] = round(d["revenue"], 2) + + if granularity == "daily": + for i, d in enumerate(arr): + window = arr[max(0, i - 6): i + 1] + d["rolling7"] = round(sum(x["revenue"] for x in window) / len(window), 2) + + return {"trends": arr, "granularity": granularity} + + +# --------------------------------------------------------------------------- +# Category performance +# --------------------------------------------------------------------------- + +@router.get("/categories/performance") +def category_performance( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + business_day_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + from models.product import Product, Category + + q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"])) + if from_dt: + q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt)) + if to_dt: + q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt)) + if business_day_id: + q = q.join(Order).filter(Order.business_day_id == business_day_id) + items = q.all() + + products_db = {p.id: p for p in db.query(Product).all()} + categories_db = {c.id: c for c in db.query(Category).all()} + + summary: dict = {} + for item in items: + product = products_db.get(item.product_id) + if not product: + continue + cid = product.category_id + if cid not in summary: + cat = categories_db.get(cid) + summary[cid] = { + "category_id": cid, + "category_name": cat.name if cat else f"#{cid}", + "color": cat.color if cat and hasattr(cat, "color") else None, + "units_sold": 0, + "revenue": 0.0, + "product_ids": set(), + } + summary[cid]["units_sold"] += item.quantity + summary[cid]["revenue"] += item.unit_price * item.quantity + summary[cid]["product_ids"].add(item.product_id) + + total_rev = sum(v["revenue"] for v in summary.values()) + result = [] + for entry in summary.values(): + entry["product_count"] = len(entry.pop("product_ids")) + entry["revenue"] = round(entry["revenue"], 2) + entry["pct"] = round((entry["revenue"] / total_rev * 100) if total_rev else 0, 1) + result.append(entry) + + result.sort(key=lambda x: x["revenue"], reverse=True) + return {"categories": result} + + +# --------------------------------------------------------------------------- +# Cancellations log +# --------------------------------------------------------------------------- + +@router.get("/cancellations") +def cancellations_log( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + business_day_id: Optional[int] = None, + waiter_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + q = db.query(OrderItem).filter(OrderItem.status == "cancelled") + if from_dt: + q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt)) + if to_dt: + q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt)) + if business_day_id: + q = q.join(Order).filter(Order.business_day_id == business_day_id) + if waiter_id: + q = q.filter(OrderItem.cancelled_by == waiter_id) + + items = q.order_by(OrderItem.added_at.desc()).all() + waiters_db = {u.id: u for u in db.query(User).all()} + tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + + result = [] + for item in items: + order = db.query(Order).filter(Order.id == item.order_id).first() + table_name = tables_db.get(order.table_id, f"#{order.table_id}") if order else "—" + waiter = waiters_db.get(item.added_by) + waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}" + cancelled_by_user = waiters_db.get(item.cancelled_by) if item.cancelled_by else None + cancelled_by_name = (cancelled_by_user.full_name or cancelled_by_user.username) if cancelled_by_user else None + product_name = item.product.name if item.product else f"#{item.product_id}" + cancelled_at = getattr(item, "cancelled_at", None) + result.append({ + "id": item.id, + "order_id": item.order_id, + "table": table_name, + "waiter_name": waiter_name, + "product_name": product_name, + "quantity": item.quantity, + "unit_price": item.unit_price, + "value": round(item.unit_price * item.quantity, 2), + "cancelled_by": cancelled_by_name, + "cancel_reason": getattr(item, "cancel_reason", None), + "cancelled_at": _dt(cancelled_at) if cancelled_at else _dt(item.added_at), + }) + + total_value = sum(r["value"] for r in result) + return {"cancellations": result, "total_value": round(total_value, 2)} + + +# --------------------------------------------------------------------------- +# Printer history (detailed log) +# --------------------------------------------------------------------------- + +@router.get("/printers/history") +def printer_history( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + business_day_id: Optional[int] = None, + printer_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + q = db.query(PrintLog) + if from_dt: + q = q.filter(PrintLog.printed_at >= datetime.fromisoformat(from_dt)) + if to_dt: + q = q.filter(PrintLog.printed_at <= datetime.fromisoformat(to_dt)) + if printer_id: + q = q.filter(PrintLog.printer_id == printer_id) + if business_day_id: + q = q.join(Order).filter(Order.business_day_id == business_day_id) + + logs = q.order_by(PrintLog.printed_at.desc()).all() + printers_db = {p.id: p for p in db.query(Printer).all()} + tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + + result = [] + for log in logs: + order = db.query(Order).filter(Order.id == log.order_id).first() + printer = printers_db.get(log.printer_id) + try: + item_ids = json.loads(log.item_ids) + except Exception: + item_ids = [] + items = [] + for iid in item_ids: + oi = db.query(OrderItem).filter(OrderItem.id == iid).first() + if oi: + pname = oi.product.name if oi.product else f"#{oi.product_id}" + items.append({"name": pname, "quantity": oi.quantity}) + result.append({ + "id": log.id, + "printed_at": _dt(log.printed_at), + "printer_name": printer.name if printer else f"#{log.printer_id}", + "order_id": log.order_id, + "table": tables_db.get(order.table_id, "—") if order else "—", + "items": items, + "success": log.success, + "error_message": log.error_message, + }) + + total = len(result) + failed = sum(1 for r in result if not r["success"]) + return {"logs": result, "total": total, "failed": failed} + + +# --------------------------------------------------------------------------- +# Waiters list (for filter dropdowns) +# --------------------------------------------------------------------------- + +@router.get("/meta/waiters") +def meta_waiters( + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all() + return {"waiters": [{"id": w.id, "name": w.full_name or w.username} for w in waiters]} + + +@router.get("/meta/tables") +def meta_tables( + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + tables = db.query(Table).filter(Table.is_active == True).all() + return {"tables": [{"id": t.id, "name": t.label or f"T{t.number}", "group": t.group.name if t.group else None} for t in tables]} + + +@router.get("/meta/printers") +def meta_printers( + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + printers = db.query(Printer).filter(Printer.is_active == True).all() + return {"printers": [{"id": p.id, "name": p.name} for p in printers]} + + +# --------------------------------------------------------------------------- +# CSV export endpoints +# --------------------------------------------------------------------------- + +def _csv_response(rows: list, filename: str) -> StreamingResponse: + if not rows: + rows = [{}] + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/shifts/export") +def shifts_export( + waiter_id: Optional[int] = None, + business_day_id: Optional[int] = None, + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + data = shifts_report(waiter_id=waiter_id, business_day_id=business_day_id, from_dt=from_dt, to_dt=to_dt, active_only=False, db=db, user=user) + rows = [] + for s in data["shifts"]: + rows.append({ + "id": s["id"], + "waiter": s["waiter_name"], + "started_at": s["started_at"], + "ended_at": s["ended_at"] or "", + "starting_cash": s["starting_cash"], + "total_collected": s["total_collected"], + "net_to_deliver": s["net_to_deliver"], + "status": "active" if s["is_active"] else "ended", + }) + date_str = (from_dt or "")[:10] + return _csv_response(rows, f"shifts-{date_str}.csv") + + +@router.get("/orders/export") +def orders_export( + from_date: Optional[str] = Query(default=None, alias="from"), + to_date: Optional[str] = Query(default=None, alias="to"), + waiter_id: Optional[int] = None, + order_status: Optional[str] = Query(default=None, alias="status"), + table_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + orders = order_history(from_date=from_date, to_date=to_date, waiter_id=waiter_id, order_status=order_status, table_id=table_id, page=1, page_size=10000, db=db, user=user) + tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + waiters_db = {u.id: u for u in db.query(User).all()} + rows = [] + for o in orders: + waiter = waiters_db.get(o.opened_by) + rows.append({ + "id": o.id, + "table": tables_db.get(o.table_id, ""), + "waiter": (waiter.full_name or waiter.username) if waiter else "", + "opened_at": _dt(o.opened_at), + "closed_at": _dt(o.closed_at) if o.closed_at else "", + "status": o.status, + "total": round(sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")), 2), + }) + date_str = (from_date or "")[:10] + return _csv_response(rows, f"orders-{date_str}.csv") + + +@router.get("/products/export") +def products_export( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + business_day_id: Optional[int] = None, + category_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + data = product_performance(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, category_id=category_id, db=db, user=user) + rows = [{ + "product_id": p["product_id"], + "product_name": p["product_name"], + "category_id": p.get("category_id", ""), + "qty_sold": p["qty_sold"], + "revenue": p["revenue"], + "order_count": p["order_count"], + } for p in data["products"]] + date_str = (from_dt or "")[:10] + return _csv_response(rows, f"products-{date_str}.csv") + + +@router.get("/printers/export") +def printers_export( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + printer_id: Optional[int] = None, + business_day_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + data = printer_history(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, printer_id=printer_id, db=db, user=user) + rows = [{ + "id": r["id"], + "printed_at": r["printed_at"], + "printer": r["printer_name"], + "order_id": r["order_id"], + "table": r["table"], + "success": r["success"], + "error_message": r.get("error_message") or "", + } for r in data["logs"]] + date_str = (from_dt or "")[:10] + return _csv_response(rows, f"printer-history-{date_str}.csv") + + +@router.get("/cancellations/export") +def cancellations_export( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + business_day_id: Optional[int] = None, + waiter_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + data = cancellations_log(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, waiter_id=waiter_id, db=db, user=user) + rows = [{ + "id": c["id"], + "order_id": c["order_id"], + "table": c["table"], + "waiter": c["waiter_name"], + "product": c["product_name"], + "quantity": c["quantity"], + "value": c["value"], + "cancelled_by": c.get("cancelled_by") or "", + "reason": c.get("cancel_reason") or "", + "cancelled_at": c["cancelled_at"], + } for c in data["cancellations"]] + date_str = (from_dt or "")[:10] + return _csv_response(rows, f"cancellations-{date_str}.csv") diff --git a/local_backend/routers/settings.py b/local_backend/routers/settings.py new file mode 100644 index 0000000..7cb3890 --- /dev/null +++ b/local_backend/routers/settings.py @@ -0,0 +1,105 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime, timezone + +from database import get_db +from models.settings import PosSettings +from schemas.settings import UpdateSettingRequest +from routers.deps import get_current_user, require_manager +from models.user import User + +router = APIRouter() + +VALID_SETTINGS = { + # Security / auth + "security.login_method": "How managers authenticate on first login: 'password' | 'pin' | 'none'", + "security.autofill_username": "Auto-fill username when only one manager exists: 'true' | 'false'", + "security.auto_lock": "Lock screen after inactivity: 'true' | 'false'", + "security.auto_lock_seconds": "Seconds of inactivity before locking (0 = disabled)", + "security.auto_logout": "Log out after inactivity: 'true' | 'false'", + "security.auto_logout_seconds":"Seconds of inactivity before logging out (0 = disabled)", + "shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action", + "shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action", + "business_day.force_close_allowed": "Allow force-closing business day with open tables", + "system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.", + "ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.", + "dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.", + # Print layout + "print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'", + "print.divider_style": "Divider character used between sections: dash, equals, star, or empty", + # Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1 + "print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS", + "print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS", + "print.font_item_name": "Font for item name lines: SIZE:BOLD:CAPS", + "print.font_quick": "Font for quick option lines (* marker): SIZE:BOLD:CAPS", + "print.font_pref": "Font for preference choice lines (> marker): SIZE:BOLD:CAPS", + "print.font_extra": "Font for extra/option lines (+ marker): SIZE:BOLD:CAPS", + "print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS", + "print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS", + "print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS", +} + +DEFAULTS = { + "security.login_method": "password", + "security.autofill_username": "true", + "security.auto_lock": "false", + "security.auto_lock_seconds": "300", + "security.auto_logout": "false", + "security.auto_logout_seconds": "1800", + "shifts.waiter_self_start": "true", + "shifts.waiter_self_end": "true", + "business_day.force_close_allowed": "true", + "system.timezone": "Europe/Athens", + "ui.table_colours": "", + "dev.spoof_printing": "false", + "print.ticket_mode": "detailed", + "print.divider_style": "dash", + "print.font_order_number": "48:1:0", + "print.font_meta": "0:0:0", + "print.font_item_name": "16:1:0", + "print.font_quick": "0:0:0", + "print.font_pref": "0:0:0", + "print.font_extra": "0:0:0", + "print.font_ingredient": "0:0:0", + "print.font_item_note": "0:0:0", + "print.font_order_note": "0:1:0", +} + + +@router.get("/") +def get_all_settings( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + stored = {s.key: s.value for s in db.query(PosSettings).all()} + result = {} + for key, description in VALID_SETTINGS.items(): + result[key] = { + "value": stored.get(key, DEFAULTS.get(key, "true")), + "description": description, + } + return result + + +@router.put("/{key}") +def update_setting( + key: str, + body: UpdateSettingRequest, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + if key not in VALID_SETTINGS: + raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}") + + setting = db.query(PosSettings).filter(PosSettings.key == key).first() + if setting: + setting.value = body.value + setting.updated_at = datetime.now(timezone.utc) + setting.updated_by_id = user.id + else: + setting = PosSettings(key=key, value=body.value, updated_by_id=user.id) + db.add(setting) + + db.commit() + db.refresh(setting) + return {"key": setting.key, "value": setting.value} diff --git a/local_backend/routers/setup.py b/local_backend/routers/setup.py new file mode 100644 index 0000000..78f8b0d --- /dev/null +++ b/local_backend/routers/setup.py @@ -0,0 +1,116 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, EmailStr +from typing import Optional +import bcrypt + +from database import get_db +from models.user import User + +router = APIRouter() + + +class SetupStatusResponse(BaseModel): + needs_setup: bool + + +class SetupInitRequest(BaseModel): + username: str + password: str + email: Optional[str] = None + full_name: Optional[str] = None + venue_type: Optional[str] = None + venue_name: Optional[str] = None + pin: Optional[str] = None + + +class SetupInitResponse(BaseModel): + ok: bool + + +class SecurityConfigResponse(BaseModel): + login_method: str + autofill_username: bool + + +@router.get("/security-config", response_model=SecurityConfigResponse) +def security_config(db: Session = Depends(get_db)): + """Public endpoint — returns only the security settings needed by the login page.""" + from models.settings import PosSettings + rows = {r.key: r.value for r in db.query(PosSettings).filter( + PosSettings.key.in_(["security.login_method", "security.autofill_username"]) + ).all()} + return SecurityConfigResponse( + login_method=rows.get("security.login_method", "password"), + autofill_username=rows.get("security.autofill_username", "true") == "true", + ) + + +@router.get("/status", response_model=SetupStatusResponse) +def setup_status(db: Session = Depends(get_db)): + has_manager = db.query(User).filter( + User.role.in_(["manager", "sysadmin"]), + User.is_active == True, + ).first() + return SetupStatusResponse(needs_setup=has_manager is None) + + +@router.post("/init", response_model=SetupInitResponse) +def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)): + has_manager = db.query(User).filter( + User.role.in_(["manager", "sysadmin"]), + User.is_active == True, + ).first() + if has_manager: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Setup already completed — a manager account already exists.", + ) + + if not body.username.strip(): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Username is required.") + if len(body.password) < 6: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Password must be at least 6 characters.") + + existing = db.query(User).filter(User.username == body.username.strip()).first() + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.") + + password_hash = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode() + + raw_pin = body.pin if (body.pin and body.pin.isdigit() and 4 <= len(body.pin) <= 6) else "0000" + pin_hash = bcrypt.hashpw(raw_pin.encode(), bcrypt.gensalt()).decode() + + user = User( + username=body.username.strip(), + pin_hash=pin_hash, + password_hash=password_hash, + email=body.email, + full_name=body.full_name, + role="manager", + is_active=True, + ) + db.add(user) + + # Persist venue settings if provided + if body.venue_name or body.venue_type: + from models.settings import PosSettings + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + if body.venue_name: + setting = db.query(PosSettings).filter(PosSettings.key == "venue.name").first() + if setting: + setting.value = body.venue_name + setting.updated_at = now + else: + db.add(PosSettings(key="venue.name", value=body.venue_name, updated_at=now)) + if body.venue_type: + setting = db.query(PosSettings).filter(PosSettings.key == "venue.type").first() + if setting: + setting.value = body.venue_type + setting.updated_at = now + else: + db.add(PosSettings(key="venue.type", value=body.venue_type, updated_at=now)) + + db.commit() + return SetupInitResponse(ok=True) diff --git a/local_backend/routers/shifts.py b/local_backend/routers/shifts.py new file mode 100644 index 0000000..be89b6c --- /dev/null +++ b/local_backend/routers/shifts.py @@ -0,0 +1,359 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import Optional +from datetime import datetime, timezone + +from database import get_db +from models.shift import WaiterShift, ShiftBreak +from models.business_day import BusinessDay +from models.order import OrderItem +from models.settings import PosSettings +from models.user import User +from schemas.shift import StartShiftRequest, EndShiftRequest +from routers.deps import get_current_user, require_manager + +router = APIRouter() + + +def _dt(dt): + """Serialize a naive-UTC datetime to ISO string with Z so JS parses it as UTC.""" + if dt is None: + return None + return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat() + + +def _get_setting(db: Session, key: str, default: str = "true") -> str: + s = db.query(PosSettings).filter(PosSettings.key == key).first() + return s.value if s else default + + +def compute_shift_total(shift_id: int, db: Session) -> float: + items = db.query(OrderItem).filter( + OrderItem.paid_in_shift_id == shift_id, + OrderItem.status == "paid", + ).all() + return round(sum(i.unit_price * i.quantity for i in items), 2) + + +def _enrich_shift(shift: WaiterShift, db: Session) -> dict: + w = shift.waiter + wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}" + total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0) + return { + "id": shift.id, + "waiter_id": shift.waiter_id, + "waiter_name": wname, + "business_day_id": shift.business_day_id, + "started_at": _dt(shift.started_at), + "ended_at": _dt(shift.ended_at), + "starting_cash": shift.starting_cash, + "total_collected": total, + "net_to_deliver": round(total + (shift.starting_cash or 0.0), 2), + "is_active": shift.ended_at is None, + "notes": shift.notes, + "breaks": [ + {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)} + for b in shift.breaks + ], + } + + +@router.get("/my") +def my_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + shift = db.query(WaiterShift).filter( + WaiterShift.waiter_id == user.id, + WaiterShift.ended_at == None, + ).first() + return _enrich_shift(shift, db) if shift else None + + +@router.post("/start", status_code=status.HTTP_201_CREATED) +def start_shift( + body: StartShiftRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + target_id = body.waiter_id + + if target_id and target_id != user.id: + if user.role not in ("manager", "sysadmin"): + raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters") + target = db.query(User).filter(User.id == target_id, User.is_active == True).first() + if not target: + raise HTTPException(status_code=404, detail="Waiter not found") + else: + target_id = user.id + if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true": + raise HTTPException(status_code=403, detail="Shift start requires manager confirmation") + + active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() + if not active_day: + raise HTTPException(status_code=400, detail="No open business day — manager must open the restaurant first") + + existing = db.query(WaiterShift).filter( + WaiterShift.waiter_id == target_id, + WaiterShift.ended_at == None, + ).first() + if existing: + raise HTTPException(status_code=400, detail="Waiter already has an active shift") + + shift = WaiterShift( + waiter_id=target_id, + business_day_id=active_day.id, + starting_cash=body.starting_cash, + ) + db.add(shift) + db.commit() + db.refresh(shift) + return _enrich_shift(shift, db) + + +@router.post("/end") +def end_shift( + body: EndShiftRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true": + raise HTTPException(status_code=403, detail="Shift end requires manager confirmation") + + shift = db.query(WaiterShift).filter( + WaiterShift.waiter_id == user.id, + WaiterShift.ended_at == None, + ).first() + if not shift: + raise HTTPException(status_code=404, detail="No active shift found") + + now = datetime.now(timezone.utc) + shift.total_collected = compute_shift_total(shift.id, db) + shift.ended_at = now + if body.notes: + shift.notes = body.notes + + open_break = db.query(ShiftBreak).filter( + ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None + ).first() + if open_break: + open_break.ended_at = now + + db.commit() + db.refresh(shift) + return _enrich_shift(shift, db) + + +@router.post("/manager/start", status_code=status.HTTP_201_CREATED) +def manager_start_shift( + body: StartShiftRequest, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + if not body.waiter_id: + raise HTTPException(status_code=400, detail="waiter_id is required") + + target = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first() + if not target: + raise HTTPException(status_code=404, detail="Waiter not found") + + active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() + if not active_day: + raise HTTPException(status_code=400, detail="No open business day") + + existing = db.query(WaiterShift).filter( + WaiterShift.waiter_id == body.waiter_id, + WaiterShift.ended_at == None, + ).first() + if existing: + raise HTTPException(status_code=400, detail="Waiter already has an active shift") + + shift = WaiterShift( + waiter_id=body.waiter_id, + business_day_id=active_day.id, + starting_cash=body.starting_cash, + ) + db.add(shift) + db.commit() + db.refresh(shift) + return _enrich_shift(shift, db) + + +@router.post("/manager/end/{shift_id}") +def manager_end_shift( + shift_id: int, + body: EndShiftRequest, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + shift = db.query(WaiterShift).filter( + WaiterShift.id == shift_id, + WaiterShift.ended_at == None, + ).first() + if not shift: + raise HTTPException(status_code=404, detail="Active shift not found") + + now = datetime.now(timezone.utc) + shift.total_collected = compute_shift_total(shift.id, db) + shift.ended_at = now + if body.notes: + shift.notes = body.notes + + open_break = db.query(ShiftBreak).filter( + ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None + ).first() + if open_break: + open_break.ended_at = now + + db.commit() + db.refresh(shift) + return _enrich_shift(shift, db) + + +@router.post("/{shift_id}/break/start") +def start_break( + shift_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() + if not shift: + raise HTTPException(status_code=404, detail="Shift not found") + if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"): + raise HTTPException(status_code=403, detail="Access denied") + if shift.ended_at: + raise HTTPException(status_code=400, detail="Shift already ended") + + open_break = db.query(ShiftBreak).filter( + ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None + ).first() + if open_break: + raise HTTPException(status_code=400, detail="Break already in progress") + + b = ShiftBreak(shift_id=shift_id) + db.add(b) + db.commit() + db.refresh(b) + return {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)} + + +@router.post("/{shift_id}/break/end") +def end_break( + shift_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() + if not shift: + raise HTTPException(status_code=404, detail="Shift not found") + if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"): + raise HTTPException(status_code=403, detail="Access denied") + + open_break = db.query(ShiftBreak).filter( + ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None + ).first() + if not open_break: + raise HTTPException(status_code=404, detail="No active break found") + + open_break.ended_at = datetime.now(timezone.utc) + db.commit() + db.refresh(open_break) + return {"id": open_break.id, "shift_id": open_break.shift_id, "started_at": _dt(open_break.started_at), "ended_at": _dt(open_break.ended_at)} + + +@router.get("/") +def list_shifts( + waiter_id: Optional[int] = None, + business_day_id: Optional[int] = None, + active_only: bool = False, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + q = db.query(WaiterShift) + if waiter_id: + q = q.filter(WaiterShift.waiter_id == waiter_id) + if business_day_id: + q = q.filter(WaiterShift.business_day_id == business_day_id) + if active_only: + q = q.filter(WaiterShift.ended_at == None) + shifts = q.order_by(WaiterShift.started_at.desc()).all() + return {"shifts": [_enrich_shift(s, db) for s in shifts]} + + +@router.get("/{shift_id}") +def get_shift( + shift_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() + if not shift: + raise HTTPException(status_code=404, detail="Shift not found") + return _enrich_shift(shift, db) + + +@router.get("/{shift_id}/summary") +def get_shift_summary( + shift_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + """Full shift summary: enriched shift data + paid items grouped by order.""" + from models.order import Order + from sqlalchemy.orm import joinedload + + shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() + if not shift: + raise HTTPException(status_code=404, detail="Shift not found") + + from models.table import Table + items = db.query(OrderItem).options( + joinedload(OrderItem.product), + joinedload(OrderItem.order), + ).filter( + OrderItem.paid_in_shift_id == shift_id, + OrderItem.status == "paid", + ).all() + + # Build table_id -> display name map for all referenced tables + table_ids = {item.order.table_id for item in items if item.order and item.order.table_id} + tables_map: dict[int, str] = {} + if table_ids: + tbl_rows = db.query(Table).filter(Table.id.in_(table_ids)).all() + for t in tbl_rows: + prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else "" + tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}" + + orders_seen = {} + for item in items: + oid = item.order_id + if oid not in orders_seen: + o = item.order + tid = o.table_id if o else None + orders_seen[oid] = { + "order_id": oid, + "table_id": tid, + "table_name": tables_map.get(tid) if tid else None, + "opened_at": _dt(o.opened_at) if o else None, + "items": [], + } + orders_seen[oid]["items"].append({ + "id": item.id, + "product_name": item.product.name if item.product else f"#{item.product_id}", + "quantity": item.quantity, + "unit_price": float(item.unit_price), + "subtotal": round(float(item.unit_price) * item.quantity, 2), + "paid_at": _dt(item.paid_at), + }) + + # Compute hours worked + started = shift.started_at + ended = shift.ended_at + duration_minutes = None + if started and ended: + duration_minutes = int((ended - started).total_seconds() / 60) + elif started: + from datetime import datetime, timezone as tz + duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60) + + enriched = _enrich_shift(shift, db) + enriched["orders"] = list(orders_seen.values()) + enriched["duration_minutes"] = duration_minutes + return enriched diff --git a/local_backend/routers/sse.py b/local_backend/routers/sse.py new file mode 100644 index 0000000..6ad6910 --- /dev/null +++ b/local_backend/routers/sse.py @@ -0,0 +1,60 @@ +""" +SSE stream endpoint — one long-lived GET per connected phone. + +Authentication: token passed as query param ?token= +(EventSource API in browsers cannot set custom headers, so query param is the standard pattern.) + +The client receives a stream of JSON lines: + data: {"type": "...", "data": {...}}\n\n + +A keepalive comment (": ping") is sent every 25 seconds to prevent proxy timeouts. +""" + +import asyncio +from fastapi import APIRouter, Query +from fastapi.responses import StreamingResponse + +from routers.deps import decode_token +from services.sse_bus import subscribe, unsubscribe + +router = APIRouter() + +KEEPALIVE_INTERVAL = 25 # seconds + + +async def _event_stream(user_id: int): + q = await subscribe(user_id) + try: + while True: + try: + payload = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL) + yield f"data: {payload}\n\n" + except asyncio.TimeoutError: + # keepalive — prevents nginx/proxies from closing idle connections + yield ": ping\n\n" + except asyncio.CancelledError: + pass + finally: + await unsubscribe(user_id, q) + + +@router.get("/stream") +async def sse_stream(token: str = Query(...)): + """ + Open an SSE stream for the authenticated user. + The phone connects once on login and stays connected. + On reconnect (after network drop) it does a full GET first, then reconnects here. + """ + # decode_token raises HTTPException on invalid/expired — no manual check needed + payload = decode_token(token) + user_id: int = int(payload["sub"]) + + return StreamingResponse( + _event_stream(user_id), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", # disable nginx buffering + "Connection": "keep-alive", + }, + ) diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py new file mode 100644 index 0000000..c2e9fdd --- /dev/null +++ b/local_backend/routers/system.py @@ -0,0 +1,179 @@ +import time +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List + +from database import get_db +from models.printer import Printer +from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut +from routers.deps import get_current_user, require_manager, require_sysadmin +from models.user import User +from models.product import Category, Product +from models.table import Table, TableGroup +from services import printer_service +from services.cloud_sync import _sync_once +from middleware.license_check import license_state +from config import settings + +router = APIRouter() + +_start_time = time.time() + + +@router.get("/health") +def health(): + return {"status": "ok", "version": settings.VERSION} + + +@router.get("/status") +def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + from datetime import datetime, timezone + printers = db.query(Printer).filter(Printer.is_active == True).all() + printer_statuses = [] + for p in printers: + reachable = printer_service.check_printer(p.ip_address, p.port) + printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable}) + + licensed = license_state.get("licensed", True) + locked = license_state.get("locked", False) + lock_pending = license_state.get("lock_pending", False) + expires_at = license_state.get("expires_at") + days_until_expiry = license_state.get("days_until_expiry") + grace_expires_at = license_state.get("grace_expires_at") + + # Determine lock_reason for the frontend banner logic + # "admin" — locked by sysadmin (immediately or deferred) + # "expired" — license grace period over, site is blocked + # None — all good + lock_reason = None + if locked or lock_pending: + lock_reason = "admin" + elif not licensed: + lock_reason = "expired" + + # Grace days remaining (only meaningful while in expiry grace period) + grace_days_remaining = None + if grace_expires_at: + try: + grace_dt = datetime.fromisoformat(grace_expires_at) + if grace_dt.tzinfo is None: + grace_dt = grace_dt.replace(tzinfo=timezone.utc) + grace_days_remaining = max(0, (grace_dt - datetime.now(timezone.utc)).days) + except ValueError: + pass + + return { + "uptime_seconds": int(time.time() - _start_time), + "version": settings.VERSION, + "latest_version": license_state.get("latest_version"), + "licensed": licensed, + "locked": locked, + "lock_pending": lock_pending, + "lock_reason": lock_reason, + "expires_at": expires_at, + "days_until_expiry": days_until_expiry, + "grace_expires_at": grace_expires_at, + "grace_days_remaining": grace_days_remaining, + "sync_failed": license_state.get("sync_failed", False), + "last_sync": license_state.get("last_sync"), + "waiter_domain": license_state.get("waiter_domain"), + "printers": printer_statuses, + } + + +@router.post("/sync-license") +async def sync_license_now(user: User = Depends(require_manager)): + """Trigger an immediate cloud heartbeat and return the fresh license state.""" + await _sync_once() + return { + "licensed": license_state.get("licensed", True), + "locked": license_state.get("locked", False), + "lock_pending": license_state.get("lock_pending", False), + "lock_reason": ( + "admin" if (license_state.get("locked") or license_state.get("lock_pending")) + else "expired" if not license_state.get("licensed", True) + else None + ), + "expires_at": license_state.get("expires_at"), + "days_until_expiry": license_state.get("days_until_expiry"), + "sync_failed": license_state.get("sync_failed", False), + "last_sync": license_state.get("last_sync"), + } + + +@router.get("/printers", response_model=List[PrinterOut]) +def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)): + return db.query(Printer).all() + + +@router.post("/printers", response_model=PrinterOut) +def create_printer(body: PrinterCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + printer = Printer(**body.model_dump()) + db.add(printer) + db.commit() + db.refresh(printer) + return printer + + +@router.post("/printers/test") +def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + printer = db.query(Printer).filter(Printer.id == printer_id).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found") + success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name) + return {"success": success, "error": error} + + +@router.post("/printers/test-order") +def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + printer = db.query(Printer).filter(Printer.id == printer_id).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found") + success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db) + return {"success": success, "error": error} + + +@router.put("/printers/{printer_id}", response_model=PrinterOut) +def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + printer = db.query(Printer).filter(Printer.id == printer_id).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(printer, field, value) + db.commit() + db.refresh(printer) + return printer + + +@router.delete("/printers/{printer_id}") +def delete_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + printer = db.query(Printer).filter(Printer.id == printer_id).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found") + db.delete(printer) + db.commit() + return {"ok": True} + + +@router.get("/stats") +def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + return { + "categories": db.query(Category).count(), + "products": db.query(Product).filter(Product.lifecycle_status == "active").count(), + "tables": db.query(Table).filter(Table.is_active == True).count(), + "table_groups": db.query(TableGroup).count(), + "managers": db.query(User).filter(User.role == "manager", User.is_active == True).count(), + "waiters": db.query(User).filter(User.role == "waiter", User.is_active == True).count(), + } + + +@router.post("/lock") +def lock_system(token: str, user: User = Depends(require_sysadmin)): + license_state["locked"] = True + return {"status": "locked"} + + +@router.post("/unlock") +def unlock_system(token: str, user: User = Depends(require_sysadmin)): + license_state["locked"] = False + return {"status": "unlocked"} diff --git a/local_backend/routers/tables.py b/local_backend/routers/tables.py new file mode 100644 index 0000000..1fde1a8 --- /dev/null +++ b/local_backend/routers/tables.py @@ -0,0 +1,227 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from database import get_db +from models.table import Table, TableGroup +from models.order import Order +from models.user import User, WaiterZone +from schemas.table import ( + TableCreate, TableUpdate, TableFloorplanUpdate, TableOut, + TableGroupCreate, TableGroupUpdate, TableGroupOut, + TableBatchCreate, MAX_TABLE_NAME_LENGTH, +) +from routers.deps import get_current_user, require_manager +from services.sse_bus import broadcast_sync + +router = APIRouter() + + +# ── Table Groups ────────────────────────────────────────────────────────────── + +@router.get("/groups", response_model=List[TableGroupOut]) +def list_groups(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + return db.query(TableGroup).order_by(TableGroup.sort_order).all() + + +@router.post("/groups", response_model=TableGroupOut, status_code=status.HTTP_201_CREATED) +def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + if db.query(TableGroup).filter(TableGroup.name == body.name).first(): + raise HTTPException(status_code=400, detail="Group name already exists") + sort_order = db.query(TableGroup).count() + group = TableGroup(name=body.name, prefix=body.prefix, color=body.color, sort_order=sort_order) + db.add(group) + db.commit() + db.refresh(group) + return group + + +@router.put("/groups/{group_id}", response_model=TableGroupOut) +def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + group = db.query(TableGroup).filter(TableGroup.id == group_id).first() + if not group: + raise HTTPException(status_code=404, detail="Group not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(group, field, value) + db.commit() + db.refresh(group) + return group + + +@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + group = db.query(TableGroup).filter(TableGroup.id == group_id).first() + if not group: + raise HTTPException(status_code=404, detail="Group not found") + db.query(Table).filter(Table.group_id == group_id).update({"group_id": None}) + db.delete(group) + db.commit() + + +# ── Tables ──────────────────────────────────────────────────────────────────── + +def _next_global_number(db: Session) -> int: + last = db.query(Table).order_by(Table.number.desc()).first() + return (last.number + 1) if last else 1 + + +@router.get("/", response_model=List[TableOut]) +def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + q = db.query(Table) + if not include_inactive: + q = q.filter(Table.is_active == True) + + # Zone-based filtering for waiters + if user.role not in ("manager", "sysadmin"): + zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all() + # No zone rows → sees nothing + if not zones: + return [] + # Any row with group_id=None → sees all tables (all-zones sentinel) + has_all_zones = any(z.group_id is None for z in zones) + if not has_all_zones: + allowed_group_ids = [z.group_id for z in zones] + q = q.filter(Table.group_id.in_(allowed_group_ids)) + + tables = q.order_by(Table.group_id, Table.number).all() + + active_table_ids = { + row[0] for row in db.query(Order.table_id).filter( + Order.status.in_(["open", "partially_paid", "paid"]) + ).all() + } + + result = [] + for t in tables: + out = TableOut.model_validate(t) + out.has_active_order = t.id in active_table_ids + result.append(out) + return result + + +@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED) +def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + number = _next_global_number(db) + table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True) + db.add(table) + db.commit() + db.refresh(table) + broadcast_sync("table_list_changed", {"action": "created", "table_id": table.id}) + return table + + +@router.post("/batch", response_model=List[TableOut], status_code=status.HTTP_201_CREATED) +def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + if body.count < 1 or body.count > 200: + raise HTTPException(status_code=400, detail="Count must be between 1 and 200") + + # Group-local label numbering: find highest suffix already used in this group + existing_in_group = ( + db.query(Table) + .filter(Table.group_id == body.group_id) + .all() + ) if body.group_id else [] + + # Extract trailing integers from existing labels that start with this prefix + used = [] + for t in existing_in_group: + if t.label and t.label.startswith(body.name_prefix): + suffix = t.label[len(body.name_prefix):] + if suffix.isdigit(): + used.append(int(suffix)) + start_label_n = (max(used) + 1) if used else 1 + + # Guard: worst-case label is prefix + highest number that will be generated + last_n = start_label_n + body.count - 1 + worst_case = f"{body.name_prefix}{last_n}" + if len(worst_case) > MAX_TABLE_NAME_LENGTH: + raise HTTPException( + status_code=400, + detail=f"Table name '{worst_case}' would exceed {MAX_TABLE_NAME_LENGTH} characters. Shorten the prefix or reduce the count.", + ) + + created = [] + for i in range(body.count): + label_n = start_label_n + i + global_number = _next_global_number(db) + table = Table( + number=global_number, + label=f"{body.name_prefix}{label_n}", + group_id=body.group_id, + is_active=True, + ) + db.add(table) + db.flush() + created.append(table) + db.commit() + for t in created: + db.refresh(t) + return created + + +@router.put("/{table_id}", response_model=TableOut) +def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + table = db.query(Table).filter(Table.id == table_id).first() + if not table: + raise HTTPException(status_code=404, detail="Table not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(table, field, value) + db.commit() + db.refresh(table) + return table + + +@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)): + table = db.query(Table).filter(Table.id == table_id).first() + if not table: + raise HTTPException(status_code=404, detail="Table not found") + active_order = db.query(Order).filter( + Order.table_id == table_id, + Order.status.in_(["open", "partially_paid", "paid"]) + ).first() + if active_order: + raise HTTPException( + status_code=400, + detail="Cannot delete or deactivate a table with an active order" + ) + if hard: + # Delete all past (non-active) orders for this table so FK constraint doesn't block deletion. + # Active orders are already blocked above. Items/waiters/print_logs cascade via ORM. + past_orders = db.query(Order).filter(Order.table_id == table_id).all() + for order in past_orders: + db.delete(order) + db.flush() + db.delete(table) + else: + table.is_active = False + db.commit() + + +@router.get("/{table_id}/status") +def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + table = db.query(Table).filter(Table.id == table_id).first() + if not table: + raise HTTPException(status_code=404, detail="Table not found") + active_order = ( + db.query(Order) + .filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid", "paid"])) + .first() + ) + return { + "table": TableOut.model_validate(table), + "active_order_id": active_order.id if active_order else None, + "order_status": active_order.status if active_order else None, + } + + +@router.put("/{table_id}/floorplan", response_model=TableOut) +def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + table = db.query(Table).filter(Table.id == table_id).first() + if not table: + raise HTTPException(status_code=404, detail="Table not found") + table.floor_x = body.floor_x + table.floor_y = body.floor_y + db.commit() + db.refresh(table) + return table diff --git a/local_backend/routers/waiters.py b/local_backend/routers/waiters.py new file mode 100644 index 0000000..b291f00 --- /dev/null +++ b/local_backend/routers/waiters.py @@ -0,0 +1,186 @@ +import os +import uuid +import bcrypt +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from sqlalchemy.orm import Session +from typing import List + +from database import get_db +from models.user import User, AssistantAssignment, WaiterZone +from models.shift import WaiterShift +from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest +from routers.deps import require_manager, get_current_user + +router = APIRouter() + +AVATAR_DIR = "/app/data/avatars" + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _waiter_or_404(waiter_id: int, db: Session) -> User: + w = db.query(User).filter(User.id == waiter_id).first() + if not w: + raise HTTPException(status_code=404, detail="Waiter not found") + return w + + +# ── CRUD ────────────────────────────────────────────────────────────────────── + +@router.get("/on-shift", response_model=List[UserOut]) +def list_waiters_on_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + """Waiters with an active (not-ended) shift. Accessible to all staff.""" + waiter_ids = db.query(WaiterShift.waiter_id).filter(WaiterShift.ended_at == None).subquery() + return db.query(User).filter(User.id.in_(waiter_ids), User.role == "waiter", User.is_active == True).all() + + +@router.get("/", response_model=List[UserOut]) +def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)): + return db.query(User).filter(User.role == "waiter").all() + + +@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED) +def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + if db.query(User).filter(User.username == body.username).first(): + raise HTTPException(status_code=400, detail="Username already exists") + pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode() + new_user = User( + username=body.username, + pin_hash=pin_hash, + role=body.role, + is_active=body.is_active, + full_name=body.full_name, + nickname=body.nickname, + mobile_phone=body.mobile_phone, + ) + db.add(new_user) + db.commit() + db.refresh(new_user) + return new_user + + +@router.put("/{waiter_id}", response_model=UserOut) +def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): + waiter = _waiter_or_404(waiter_id, db) + for field, value in body.model_dump(exclude_none=True).items(): + setattr(waiter, field, value) + db.commit() + db.refresh(waiter) + return waiter + + +@router.put("/{waiter_id}/reset-pin") +def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_manager)): + waiter = _waiter_or_404(waiter_id, db) + waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() + db.commit() + return {"status": "pin reset"} + + +@router.put("/{waiter_id}/block") +def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + waiter = _waiter_or_404(waiter_id, db) + waiter.is_active = not waiter.is_active + db.commit() + return {"is_active": waiter.is_active} + + +@router.delete("/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_waiter(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + waiter = _waiter_or_404(waiter_id, db) + db.delete(waiter) + db.commit() + + +# ── Avatar upload / delete ─────────────────────────────────────────────────── + +@router.post("/{waiter_id}/avatar", response_model=UserOut) +async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)): + waiter = _waiter_or_404(waiter_id, db) + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="File must be an image") + + # Delete old avatar file if present + if waiter.avatar_url: + old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url)) + if os.path.exists(old_path): + os.remove(old_path) + + ext = os.path.splitext(file.filename or "")[1] or ".jpg" + filename = f"waiter_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}" + dest = os.path.join(AVATAR_DIR, filename) + os.makedirs(AVATAR_DIR, exist_ok=True) + content = await file.read() + with open(dest, "wb") as f: + f.write(content) + + waiter.avatar_url = f"/static/avatars/{filename}" + db.commit() + db.refresh(waiter) + return waiter + + +@router.delete("/{waiter_id}/avatar", response_model=UserOut) +def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + waiter = _waiter_or_404(waiter_id, db) + if waiter.avatar_url: + old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url)) + if os.path.exists(old_path): + os.remove(old_path) + waiter.avatar_url = None + db.commit() + db.refresh(waiter) + return waiter + + +# ── Zone assignments ────────────────────────────────────────────────────────── + +@router.put("/{waiter_id}/zones") +def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)): + """Replace all zone assignments for a waiter atomically. + + - all_zones=True → single NULL group_id row (sees everything) + - group_ids=[1,2] → rows for groups 1 and 2 only + - group_ids=[] → no rows at all (sees nothing) + """ + _waiter_or_404(waiter_id, db) + # Wipe existing assignments + db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete() + + if body.all_zones: + db.add(WaiterZone(waiter_id=waiter_id, group_id=None)) + elif body.group_ids: + for gid in body.group_ids: + db.add(WaiterZone(waiter_id=waiter_id, group_id=gid)) + + db.commit() + zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).all() + return {"waiter_id": waiter_id, "zones": [{"id": z.id, "group_id": z.group_id} for z in zones]} + + +# ── Assistant assignments (kept for backwards compat) ───────────────────────── + +@router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut) +def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + existing = db.query(AssistantAssignment).filter( + AssistantAssignment.primary_waiter_id == waiter_id, + AssistantAssignment.assistant_waiter_id == assistant_id, + ).first() + if existing: + raise HTTPException(status_code=400, detail="Assignment already exists") + assignment = AssistantAssignment(primary_waiter_id=waiter_id, assistant_waiter_id=assistant_id) + db.add(assignment) + db.commit() + db.refresh(assignment) + return assignment + + +@router.delete("/{waiter_id}/assistant", status_code=status.HTTP_204_NO_CONTENT) +def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): + assignment = db.query(AssistantAssignment).filter( + AssistantAssignment.primary_waiter_id == waiter_id + ).first() + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + db.delete(assignment) + db.commit() diff --git a/local_backend/schemas/__init__.py b/local_backend/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_backend/schemas/auth.py b/local_backend/schemas/auth.py new file mode 100644 index 0000000..9c0415f --- /dev/null +++ b/local_backend/schemas/auth.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel +from schemas.user import UserOut + + +class LoginRequest(BaseModel): + username: str + pin: str | None = None + password: str | None = None + + +class TokenResponse(BaseModel): + access_token: str + user: UserOut + + +class UpdateMeRequest(BaseModel): + full_name: str | None = None + username: str | None = None + current_password: str | None = None + new_password: str | None = None + new_pin: str | None = None diff --git a/local_backend/schemas/base.py b/local_backend/schemas/base.py new file mode 100644 index 0000000..2b0ff50 --- /dev/null +++ b/local_backend/schemas/base.py @@ -0,0 +1,14 @@ +from datetime import datetime +from typing import Annotated +from pydantic import PlainSerializer + +# SQLite strips tzinfo on read-back, so naive datetimes from DB are actually UTC. +# This serializer appends "Z" so browsers parse them correctly as UTC. +UTCDatetime = Annotated[ + datetime, + PlainSerializer( + lambda dt: (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat(), + return_type=str, + when_used="json", + ), +] diff --git a/local_backend/schemas/business_day.py b/local_backend/schemas/business_day.py new file mode 100644 index 0000000..57abc6d --- /dev/null +++ b/local_backend/schemas/business_day.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel +from typing import Optional +from schemas.base import UTCDatetime + + +class BusinessDayOut(BaseModel): + id: int + status: str + opened_at: UTCDatetime + opened_by_id: int + closed_at: Optional[UTCDatetime] = None + closed_by_id: Optional[int] = None + notes: Optional[str] = None + + model_config = {"from_attributes": True} + + +class OpenBusinessDayRequest(BaseModel): + notes: Optional[str] = None + + +class CloseBusinessDayRequest(BaseModel): + force: bool = False + notes: Optional[str] = None diff --git a/local_backend/schemas/flag.py b/local_backend/schemas/flag.py new file mode 100644 index 0000000..19bea65 --- /dev/null +++ b/local_backend/schemas/flag.py @@ -0,0 +1,47 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + + +class FlagDefCreate(BaseModel): + name: str + emoji: Optional[str] = None + color: Optional[str] = "#6b7280" + text_color: Optional[str] = None + sort_order: Optional[int] = 0 + + +class FlagDefUpdate(BaseModel): + name: Optional[str] = None + emoji: Optional[str] = None + color: Optional[str] = None + text_color: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + + +class FlagDefOut(BaseModel): + id: int + name: str + emoji: Optional[str] = None + color: Optional[str] = None + text_color: Optional[str] = None + sort_order: int + is_active: bool + + model_config = {"from_attributes": True} + + +class FlagAssignmentOut(BaseModel): + id: int + table_id: int + flag_id: int + flag_def: Optional[FlagDefOut] = None + assigned_at: datetime + assigned_by: Optional[int] = None + + model_config = {"from_attributes": True} + + +class SetTableFlagsRequest(BaseModel): + flag_ids: List[int] diff --git a/local_backend/schemas/message.py b/local_backend/schemas/message.py new file mode 100644 index 0000000..10eda2d --- /dev/null +++ b/local_backend/schemas/message.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + + +class QuickTemplateCreate(BaseModel): + body: str + sort_order: Optional[int] = 0 + + +class QuickTemplateUpdate(BaseModel): + body: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + + +class QuickTemplateOut(BaseModel): + id: int + body: str + sort_order: int + is_active: bool + + model_config = {"from_attributes": True} + + +class SendMessageRequest(BaseModel): + body: str + target_waiter_ids: List[int] # empty = all active waiters + table_ids: Optional[List[int]] = [] + + +class StaffMessageOut(BaseModel): + id: int + sender_id: int + sender_name: Optional[str] = None + body: str + target_waiter_ids: str # raw JSON string — frontend parses + table_ids: str + created_at: datetime + acked_by: List[int] = [] # waiter ids who have acked + + model_config = {"from_attributes": True} diff --git a/local_backend/schemas/order.py b/local_backend/schemas/order.py new file mode 100644 index 0000000..3fef693 --- /dev/null +++ b/local_backend/schemas/order.py @@ -0,0 +1,126 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List +from schemas.base import UTCDatetime + + +class SelectedOptionInput(BaseModel): + id: Optional[int] = None + name: Optional[str] = None + price_delta: Optional[float] = None + extra_cost: Optional[float] = None + # type tags: "quick" | "pref" | "pref_sub" | "extra" | "extra_sub" + # Omitted by old clients — print code falls back gracefully. + type: Optional[str] = None + + +class OrderItemInput(BaseModel): + product_id: int + quantity: int + selected_options: Optional[List[SelectedOptionInput]] = None + removed_ingredients: Optional[List[str]] = None + notes: Optional[str] = None + + +class AddItemsRequest(BaseModel): + items: List[OrderItemInput] + + +class ProductNameOut(BaseModel): + id: int + name: str + model_config = {"from_attributes": True} + + +class OrderItemOut(BaseModel): + id: int + order_id: int + product_id: int + product: Optional[ProductNameOut] = None + added_by: int + quantity: int + unit_price: float + selected_options: Optional[str] = None + removed_ingredients: Optional[str] = None + notes: Optional[str] = None + status: str + added_at: UTCDatetime + printed: bool + paid_by: Optional[int] = None + paid_at: Optional[UTCDatetime] = None + payment_method: Optional[str] = None + paid_in_shift_id: Optional[int] = None + + model_config = {"from_attributes": True} + + +class PrintResultOut(BaseModel): + printer_name: str + success: bool + error: Optional[str] = None + + +class AddItemsResponse(BaseModel): + order: "OrderOut" + print_results: List[PrintResultOut] + + model_config = {"from_attributes": True} + + +class OrderCreate(BaseModel): + table_id: int + + +class PayItemsRequest(BaseModel): + item_ids: List[int] + payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now + + +class OfflinePaymentRequest(BaseModel): + uuid: str # client-generated UUID, used for duplicate detection + item_ids: List[int] + payment_method: Optional[str] = None + offline_at: Optional[str] = None # ISO timestamp of when payment was taken offline + + +class AssignWaiterRequest(BaseModel): + waiter_id: int + + +class OrderWaiterOut(BaseModel): + waiter_id: int + model_config = {"from_attributes": True} + + +class AuditLogOut(BaseModel): + id: int + order_id: int + event_type: str + waiter_id: Optional[int] = None + waiter_name: Optional[str] = None # resolved server-side + item_ids: Optional[str] = None + amount: Optional[float] = None + payment_method: Optional[str] = None + note: Optional[str] = None + created_at: UTCDatetime + offline_at: Optional[str] = None + is_duplicate: int = 0 + + model_config = {"from_attributes": True} + + +class OrderOut(BaseModel): + id: int + table_id: int + opened_by: int + opened_at: UTCDatetime + status: str + closed_at: Optional[UTCDatetime] = None + closed_by: Optional[int] = None + notes: Optional[str] = None + business_day_id: Optional[int] = None + items: List[OrderItemOut] = [] + waiters: List[OrderWaiterOut] = [] + audit_logs: List[AuditLogOut] = [] + + model_config = {"from_attributes": True} diff --git a/local_backend/schemas/printer.py b/local_backend/schemas/printer.py new file mode 100644 index 0000000..9faf8f1 --- /dev/null +++ b/local_backend/schemas/printer.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from typing import Optional + + +PROTOCOLS = ["escpos_tcp"] # extend later as needed + + +class PrinterBase(BaseModel): + name: str + ip_address: str + port: int = 9100 + is_active: bool = True + protocol: str = "escpos_tcp" + + +class PrinterCreate(PrinterBase): + pass + + +class PrinterUpdate(BaseModel): + name: Optional[str] = None + ip_address: Optional[str] = None + port: Optional[int] = None + is_active: Optional[bool] = None + protocol: Optional[str] = None + + +class PrinterOut(PrinterBase): + id: int + + model_config = {"from_attributes": True} diff --git a/local_backend/schemas/product.py b/local_backend/schemas/product.py new file mode 100644 index 0000000..a40ec99 --- /dev/null +++ b/local_backend/schemas/product.py @@ -0,0 +1,302 @@ +import json +from pydantic import BaseModel, model_validator, field_validator +from typing import Optional, List, Any + + +class CategoryCreate(BaseModel): + name: str + color: Optional[str] = None + sort_order: int = 0 + parent_id: Optional[int] = None + general_sort_order: int = 0 + auto_expanded: bool = False + + +class CategoryUpdate(BaseModel): + name: Optional[str] = None + color: Optional[str] = None + sort_order: Optional[int] = None + parent_id: Optional[int] = None + general_sort_order: Optional[int] = None + auto_expanded: Optional[bool] = None + + +class CategoryOut(BaseModel): + id: int + name: str + color: Optional[str] = None + sort_order: int = 0 + parent_id: Optional[int] = None + general_sort_order: int = 0 + auto_expanded: bool = False + + model_config = {"from_attributes": True} + + +class CategoryReparentRequest(BaseModel): + parent_id: Optional[int] = None + + +class CategoryReorderItem(BaseModel): + id: int + sort_order: int + + +class SubcategoryReorderItem(BaseModel): + id: int + sort_order: int # position among subcategories within the parent + + +class ParentGeneralReorderItem(BaseModel): + id: int # parent category id + general_sort_order: int + + +# ── Quick Options ───────────────────────────────────────────────────────────── + +class ProductQuickOptionCreate(BaseModel): + name: str + price: float = 0.0 + allow_multiple: bool = False + sort_order: int = 0 + is_favorite: bool = False + favorite_sort_order: int = 0 + is_compact: bool = False + + +class ProductQuickOptionOut(BaseModel): + id: int + product_id: int + name: str + price: float = 0.0 + allow_multiple: bool = False + sort_order: int = 0 + is_favorite: bool = False + favorite_sort_order: int = 0 + is_compact: bool = False + + model_config = {"from_attributes": True} + + +# ── Options ────────────────────────────────────────────────────────────────── + +class OptionSubChoice(BaseModel): + name: str + extra_cost: float = 0.0 + is_default: bool = False + + +class ProductOptionBase(BaseModel): + name: str + extra_cost: float = 0.0 + allow_multiple: bool = False + is_favorite: bool = False + favorite_sort_order: int = 0 + + +class ProductOptionCreate(ProductOptionBase): + sub_choices: List[OptionSubChoice] = [] + + +class ProductOptionOut(ProductOptionBase): + id: int + product_id: int + sub_choices: List[OptionSubChoice] = [] + + model_config = {"from_attributes": True} + + @model_validator(mode='before') + @classmethod + def parse_option_sub_choices(cls, data: Any) -> Any: + if hasattr(data, 'sub_choices'): + raw = data.sub_choices + parsed = json.loads(raw) if isinstance(raw, str) else [] + return { + 'id': data.id, + 'product_id': data.product_id, + 'name': data.name, + 'extra_cost': data.extra_cost, + 'allow_multiple': getattr(data, 'allow_multiple', False) or False, + 'sub_choices': parsed, + 'is_favorite': getattr(data, 'is_favorite', False) or False, + 'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0, + } + return data + + +# ── Ingredients ─────────────────────────────────────────────────────────────── + +class ProductIngredientBase(BaseModel): + name: str + extra_cost: float = 0.0 + is_favorite: bool = False + favorite_sort_order: int = 0 + + +class ProductIngredientCreate(ProductIngredientBase): + pass + + +class ProductIngredientOut(ProductIngredientBase): + id: int + product_id: int + + model_config = {"from_attributes": True} + + +# ── Sub-choices (nested under a preference choice) ──────────────────────────── + +class SubChoice(BaseModel): + name: str + extra_cost: float = 0.0 + is_default: bool = False + + +# ── Shared subset (set-level, shown for all non-disabling choices) ───────────── + +class SharedSubsetChoice(BaseModel): + name: str + extra_cost: float = 0.0 + is_default: bool = False + + +class SharedSubset(BaseModel): + name: str + choices: List[SharedSubsetChoice] = [] + + +# ── Preferences ─────────────────────────────────────────────────────────────── + +class PreferenceChoiceCreate(BaseModel): + name: str + extra_cost: float = 0.0 + sub_choices: List[SubChoice] = [] + disables_subset: bool = False + + +class PreferenceChoiceOut(BaseModel): + id: int + set_id: int + name: str + extra_cost: float = 0.0 + sub_choices: List[SubChoice] = [] + disables_subset: bool = False + + model_config = {"from_attributes": True} + + @model_validator(mode='before') + @classmethod + def parse_sub_choices(cls, data: Any) -> Any: + if hasattr(data, 'sub_choices'): + raw = data.sub_choices + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except Exception: + parsed = [] + else: + parsed = [] + return { + 'id': data.id, + 'set_id': data.set_id, + 'name': data.name, + 'extra_cost': data.extra_cost, + 'sub_choices': parsed, + 'disables_subset': data.disables_subset or False, + } + return data + + +class PreferenceSetCreate(BaseModel): + name: str + choices: List[PreferenceChoiceCreate] = [] + default_choice_index: Optional[int] = None # index into choices (0-based) + shared_subset: Optional[SharedSubset] = None + is_favorite: bool = False + favorite_sort_order: int = 0 + + +class PreferenceSetOut(BaseModel): + id: int + product_id: int + name: str + choices: List[PreferenceChoiceOut] = [] + default_choice_id: Optional[int] = None + shared_subset: Optional[SharedSubset] = None + is_favorite: bool = False + favorite_sort_order: int = 0 + + model_config = {"from_attributes": True} + + @model_validator(mode='before') + @classmethod + def parse_shared_subset(cls, data: Any) -> Any: + if hasattr(data, 'shared_subset'): + raw = data.shared_subset + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except Exception: + parsed = None + else: + parsed = None + return { + 'id': data.id, + 'product_id': data.product_id, + 'name': data.name, + 'choices': list(data.choices), + 'default_choice_id': data.default_choice_id, + 'shared_subset': parsed, + 'is_favorite': getattr(data, 'is_favorite', False) or False, + 'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0, + } + return data + + +# ── Products ────────────────────────────────────────────────────────────────── + +class ProductBase(BaseModel): + name: str + category_id: Optional[int] = None + base_price: float + is_available: bool = True + lifecycle_status: str = "active" + printer_zone_id: Optional[int] = None + sort_order: int = 0 + + +class ProductCreate(ProductBase): + quick_options: List[ProductQuickOptionCreate] = [] + options: List[ProductOptionCreate] = [] + ingredients: List[ProductIngredientCreate] = [] + preference_sets: List[PreferenceSetCreate] = [] + + +class ProductUpdate(BaseModel): + name: Optional[str] = None + category_id: Optional[int] = None + base_price: Optional[float] = None + is_available: Optional[bool] = None + lifecycle_status: Optional[str] = None + printer_zone_id: Optional[int] = None + sort_order: Optional[int] = None + quick_options: Optional[List[ProductQuickOptionCreate]] = None + options: Optional[List[ProductOptionCreate]] = None + ingredients: Optional[List[ProductIngredientCreate]] = None + preference_sets: Optional[List[PreferenceSetCreate]] = None + + +class ProductReorderItem(BaseModel): + id: int + sort_order: int + + +class ProductOut(ProductBase): + id: int + quick_options: List[ProductQuickOptionOut] = [] + options: List[ProductOptionOut] = [] + ingredients: List[ProductIngredientOut] = [] + preference_sets: List[PreferenceSetOut] = [] + image_url: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/local_backend/schemas/settings.py b/local_backend/schemas/settings.py new file mode 100644 index 0000000..327b6a2 --- /dev/null +++ b/local_backend/schemas/settings.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel +from typing import Optional +from schemas.base import UTCDatetime + + +class PosSettingOut(BaseModel): + key: str + value: str + updated_at: Optional[UTCDatetime] = None + updated_by_id: Optional[int] = None + + model_config = {"from_attributes": True} + + +class UpdateSettingRequest(BaseModel): + value: str diff --git a/local_backend/schemas/shift.py b/local_backend/schemas/shift.py new file mode 100644 index 0000000..8c50658 --- /dev/null +++ b/local_backend/schemas/shift.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel +from typing import Optional, List +from schemas.base import UTCDatetime + + +class ShiftBreakOut(BaseModel): + id: int + shift_id: int + started_at: UTCDatetime + ended_at: Optional[UTCDatetime] = None + + model_config = {"from_attributes": True} + + +class WaiterShiftOut(BaseModel): + id: int + waiter_id: int + waiter_name: Optional[str] = None + business_day_id: int + started_at: UTCDatetime + ended_at: Optional[UTCDatetime] = None + starting_cash: Optional[float] = None + total_collected: Optional[float] = None + net_to_deliver: Optional[float] = None + is_active: bool = True + notes: Optional[str] = None + breaks: List[ShiftBreakOut] = [] + + model_config = {"from_attributes": True} + + +class StartShiftRequest(BaseModel): + starting_cash: Optional[float] = None + waiter_id: Optional[int] = None # manager use: start shift for a specific waiter + + +class EndShiftRequest(BaseModel): + notes: Optional[str] = None diff --git a/local_backend/schemas/table.py b/local_backend/schemas/table.py new file mode 100644 index 0000000..21a059f --- /dev/null +++ b/local_backend/schemas/table.py @@ -0,0 +1,87 @@ +from pydantic import BaseModel, field_validator +from typing import Optional, List + +MAX_TABLE_NAME_LENGTH = 6 + + +class TableGroupCreate(BaseModel): + name: str + prefix: Optional[str] = None + color: Optional[str] = None + + +class TableGroupUpdate(BaseModel): + name: Optional[str] = None + prefix: Optional[str] = None + color: Optional[str] = None + + +class TableGroupOut(BaseModel): + id: int + name: str + prefix: Optional[str] = None + sort_order: int = 0 + color: Optional[str] = None + + model_config = {"from_attributes": True} + + +class TableBase(BaseModel): + label: Optional[str] = None + group_id: Optional[int] = None + is_active: bool = True + + +class TableCreate(BaseModel): + label: Optional[str] = None + group_id: Optional[int] = None + + @field_validator("label") + @classmethod + def label_max_length(cls, v): + if v is not None: + v = v.strip() + if len(v) > MAX_TABLE_NAME_LENGTH: + raise ValueError(f"Table name cannot exceed {MAX_TABLE_NAME_LENGTH} characters") + return v + + +class TableBatchCreate(BaseModel): + group_id: Optional[int] = None + count: int + name_prefix: str # e.g. "TBL-" → TBL-1, TBL-2 ... + # start_number is computed on the backend from existing tables in the group + + +class TableUpdate(BaseModel): + label: Optional[str] = None + group_id: Optional[int] = None + is_active: Optional[bool] = None + + @field_validator("label") + @classmethod + def label_max_length(cls, v): + if v is not None: + v = v.strip() + if len(v) > MAX_TABLE_NAME_LENGTH: + raise ValueError(f"Table name cannot exceed {MAX_TABLE_NAME_LENGTH} characters") + return v + + +class TableFloorplanUpdate(BaseModel): + floor_x: float + floor_y: float + + +class TableOut(BaseModel): + id: int + number: int + label: Optional[str] = None + group_id: Optional[int] = None + is_active: bool = True + floor_x: Optional[float] = None + floor_y: Optional[float] = None + group: Optional[TableGroupOut] = None + has_active_order: bool = False + + model_config = {"from_attributes": True} diff --git a/local_backend/schemas/user.py b/local_backend/schemas/user.py new file mode 100644 index 0000000..9330115 --- /dev/null +++ b/local_backend/schemas/user.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List +from schemas.base import UTCDatetime + + +class UserBase(BaseModel): + username: str + role: str + is_active: bool = True + full_name: Optional[str] = None + nickname: Optional[str] = None + mobile_phone: Optional[str] = None + avatar_url: Optional[str] = None + email: Optional[str] = None + + +class UserCreate(UserBase): + pin: str + + +class UserUpdate(BaseModel): + username: Optional[str] = None + role: Optional[str] = None + is_active: Optional[bool] = None + full_name: Optional[str] = None + nickname: Optional[str] = None + mobile_phone: Optional[str] = None + + +class WaiterZoneOut(BaseModel): + id: int + waiter_id: int + group_id: Optional[int] = None # None = all zones + + model_config = {"from_attributes": True} + + +class UserOut(UserBase): + id: int + created_at: UTCDatetime + zone_assignments: List[WaiterZoneOut] = [] + + model_config = {"from_attributes": True} + + +class SetZonesRequest(BaseModel): + """Replace all zone assignments for a waiter in one call. + group_ids=[] means remove all (sees nothing). + group_ids=[null] or all_zones=True means the wildcard 'all zones' sentinel.""" + group_ids: Optional[List[Optional[int]]] = None # list of group ids; None in list = all-zones sentinel + all_zones: bool = False # convenience flag: if True, set a single NULL-group_id row + + +class AssistantAssignmentOut(BaseModel): + id: int + primary_waiter_id: int + assistant_waiter_id: int + assigned_at: UTCDatetime + + model_config = {"from_attributes": True} diff --git a/local_backend/seed.py b/local_backend/seed.py new file mode 100644 index 0000000..406b6e9 --- /dev/null +++ b/local_backend/seed.py @@ -0,0 +1,45 @@ +""" +Run once to bootstrap the database with initial data. +Usage: python seed.py +""" +import bcrypt +from database import engine, Base, SessionLocal +import models.user +import models.table +import models.printer +import models.product +import models.order + +Base.metadata.create_all(bind=engine) + +db = SessionLocal() + +try: + from models.user import User + from models.printer import Printer + + # ── Manager account ─────────────────────────────────────────────────────── + if not db.query(User).filter(User.username == "manager").first(): + db.add(User( + username="manager", + pin_hash=bcrypt.hashpw(b"1234", bcrypt.gensalt()).decode(), + role="manager", + )) + print("Created manager account (PIN: 1234)") + else: + print("Manager account already exists — skipping") + + # ── Printers ────────────────────────────────────────────────────────────── + if not db.query(Printer).filter(Printer.name == "Kitchen").first(): + db.add(Printer(name="Kitchen", ip_address="10.98.20.25", port=9100)) + print("Created Kitchen printer") + + if not db.query(Printer).filter(Printer.name == "Bar").first(): + db.add(Printer(name="Bar", ip_address="10.98.20.25", port=9100)) + print("Created Bar printer (update IP when you have a second printer)") + + db.commit() + print("\nDone. Change the manager PIN after first login.") + +finally: + db.close() diff --git a/local_backend/services/__init__.py b/local_backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_backend/services/cloud_sync.py b/local_backend/services/cloud_sync.py new file mode 100644 index 0000000..0590c1b --- /dev/null +++ b/local_backend/services/cloud_sync.py @@ -0,0 +1,179 @@ +""" +Periodic cloud check-in. Runs every 5 minutes as an asyncio background task. +Grace period: 72 hours (3 days) before marking unlicensed on connectivity failure. + +Lock behaviour: + - cloud sets locked=true → set lock_pending=true in state + - lock_pending is enforced at workday-close time (see business_day router) + - while a workday is open, the site keeps running; lock applies once it closes + +License expiry behaviour: + - 5 days before expiry → warning only (days_until_expiry in state) + - on expiry → 5-day grace period begins (grace_expires_at in state) + - after grace + no open workday → licensed=False enforced by business_day router +""" +import asyncio +import json +import logging +import socket +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import httpx + +from config import settings +from middleware.license_check import license_state + +logger = logging.getLogger(__name__) + +SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes +GRACE_HOURS = 72 # 3 days offline grace +EXPIRY_GRACE_DAYS = 5 # days after expiry before blocking +EXPIRY_WARNING_DAYS = 5 # days before expiry to show warning +STATE_FILE = Path(__file__).parent.parent / "license_state.json" + + +def _load_persisted_state(): + if STATE_FILE.exists(): + try: + data = json.loads(STATE_FILE.read_text()) + license_state.update(data) + logger.info("Loaded persisted license state: %s", data) + except Exception as e: + logger.warning("Could not load license state file: %s", e) + + +def _persist_state(): + try: + STATE_FILE.write_text(json.dumps(license_state)) + except Exception as e: + logger.warning("Could not persist license state: %s", e) + + +def _compute_expiry_fields(expires_at_str: str | None) -> dict: + """Return days_until_expiry and grace_expires_at derived from expires_at.""" + if not expires_at_str: + return {"days_until_expiry": None, "grace_expires_at": None} + + try: + expires_at = datetime.fromisoformat(expires_at_str) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + except ValueError: + return {"days_until_expiry": None, "grace_expires_at": None} + + now = datetime.now(timezone.utc) + days_until = (expires_at - now).days # negative once expired + + grace_expires_at = None + if days_until < 0: + grace_expires_at = (expires_at + timedelta(days=EXPIRY_GRACE_DAYS)).isoformat() + + return { + "days_until_expiry": days_until, + "grace_expires_at": grace_expires_at, + } + + +def _get_local_ip() -> str | None: + """Best-effort detection of the machine's LAN IP address.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except Exception: + return None + + +async def _sync_once(): + if not settings.SITE_ID or not settings.CLOUD_URL: + logger.debug("No SITE_ID/CLOUD_URL configured — skipping cloud sync") + return + + try: + local_ip = _get_local_ip() + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{settings.CLOUD_URL}/api/heartbeat/", + headers={ + "X-Site-ID": settings.SITE_ID, + "X-Site-Key": settings.SITE_KEY, + }, + json={"version": settings.VERSION, "uptime_seconds": 0, "local_ip": local_ip}, + ) + resp.raise_for_status() + data = resp.json() + + licensed = data.get("licensed", True) + cloud_locked = data.get("locked", False) + expires_at = data.get("expires_at") + expiry_fields = _compute_expiry_fields(expires_at) + + # If cloud says locked, check whether a workday is currently open. + # No open workday → lock immediately. + # Open workday → defer to workday close (business_day router enforces it). + if cloud_locked: + from database import SessionLocal + from models.business_day import BusinessDay + db = SessionLocal() + try: + open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() + finally: + db.close() + + if open_day: + if not license_state.get("lock_pending"): + license_state["lock_pending"] = True + logger.info("Cloud requested lock — workday open, deferring to workday close") + else: + license_state["lock_pending"] = False + license_state["locked"] = True + logger.info("Cloud requested lock — no open workday, locking immediately") + + # If cloud lifts the lock, clear pending too + if not cloud_locked: + license_state["lock_pending"] = False + license_state["locked"] = False + + license_state.update({ + "licensed": licensed, + "expires_at": expires_at, + "latest_version": data.get("latest_version"), + "waiter_domain": data.get("waiter_domain"), + "last_sync": datetime.now(timezone.utc).isoformat(), + "sync_failed": False, + **expiry_fields, + }) + _persist_state() + logger.info("Cloud sync OK: licensed=%s locked=%s expires_at=%s", licensed, cloud_locked, expires_at) + + except Exception as e: + logger.warning("Cloud sync failed: %s", e) + license_state["sync_failed"] = True + + last_sync_str = license_state.get("last_sync") + if last_sync_str: + try: + last_sync = datetime.fromisoformat(last_sync_str) + grace_expires = last_sync + timedelta(hours=GRACE_HOURS) + if datetime.now(timezone.utc) > grace_expires: + logger.error("72-hour offline grace period expired — marking unlicensed") + license_state["licensed"] = False + except ValueError: + pass + + # Recompute expiry fields from cached expires_at even when offline + expiry_fields = _compute_expiry_fields(license_state.get("expires_at")) + license_state.update(expiry_fields) + + +async def _sync_loop(): + _load_persisted_state() + while True: + await _sync_once() + await asyncio.sleep(SYNC_INTERVAL_SECONDS) + + +async def start_cloud_sync() -> asyncio.Task: + task = asyncio.create_task(_sync_loop()) + return task diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py new file mode 100644 index 0000000..7a730de --- /dev/null +++ b/local_backend/services/printer_service.py @@ -0,0 +1,899 @@ +""" +ESC/POS printer service — Jolimark TP850UE confirmed configuration. + +Key findings from printer testing: +- Code page n=29 (CP737) is the only working Greek code page on this model. +- All Greek text MUST be sent as raw CP737 bytes via p._raw() — never p.text(). +- Set the code page immediately after connecting, before any output. +- 80mm paper = 48 chars wide at standard font. Double-height keeps 48-char width. +""" +import json +import logging +import socket +import datetime +from typing import Tuple, List + +from escpos.printer import Network +from sqlalchemy.orm import Session + +from database import SessionLocal +from models.order import Order, OrderItem, PrintLog +from models.printer import Printer +from models.product import Product +from models.settings import PosSettings + +logger = logging.getLogger(__name__) + +LINE_WIDTH = 48 +PRINTER_TIMEOUT = 5 + + +# ── Low-level helpers ──────────────────────────────────────────────────────── + +def _get_printer(ip: str, port: int) -> Network: + p = Network(ip, port, timeout=PRINTER_TIMEOUT) + p._raw(b'\x1b\x40') # ESC @ — reset printer + p._raw(b'\x1b\x74\x1d') # ESC t 29 — select CP737 (Greek) — confirmed n=29 + return p + + +def _gr(text: str) -> bytes: + """Encode text to CP737 bytes. Replaces unknown chars instead of crashing.""" + return text.encode('cp737', errors='replace') + + +def _raw_text(p: Network, text: str): + """Send text as raw CP737 bytes — the ONLY safe way to print Greek.""" + p._raw(_gr(text)) + + +_DIVIDER_CHARS = { + "dash": "-", + "equals": "=", + "star": "*", + "empty": "", +} + +_PRINT_SETTING_KEYS = [ + "print.ticket_mode", + "print.divider_style", + "print.font_order_number", + "print.font_meta", + "print.font_item_name", + "print.font_quick", + "print.font_pref", + "print.font_extra", + "print.font_ingredient", + "print.font_item_note", + "print.font_order_note", +] + +_PRINT_SETTING_DEFAULTS = { + "print.ticket_mode": "detailed", + "print.divider_style": "dash", + "print.font_order_number": "48:1:0", + "print.font_meta": "0:0:0", + "print.font_item_name": "16:1:0", + "print.font_quick": "0:0:0", + "print.font_pref": "0:0:0", + "print.font_extra": "0:0:0", + "print.font_ingredient": "0:0:0", + "print.font_item_note": "0:0:0", + "print.font_order_note": "0:1:0", +} + +# SIZE byte values (ESC ! base, no bold bit): +# 0 = normal +# 16 = double-height (bit4) +# 32 = double-width (bit5) +# 48 = double-height + double-width (bits 4+5) +# Bold applied via ESC E, caps applied in software before encoding. + +def _decode_font(value: str) -> tuple[int, bool, bool]: + """Parse 'SIZE:BOLD:CAPS' string → (esc_bang_byte, bold_flag, caps_flag).""" + try: + parts = str(value).split(":") + size = int(parts[0]) + bold = len(parts) > 1 and parts[1] == "1" + caps = len(parts) > 2 and parts[2] == "1" + return size, bold, caps + except (ValueError, AttributeError): + return 0, False, False + + +def _load_print_settings(db: Session) -> dict: + rows = db.query(PosSettings).filter( + PosSettings.key.in_(_PRINT_SETTING_KEYS) + ).all() + settings = dict(_PRINT_SETTING_DEFAULTS) + for row in rows: + settings[row.key] = row.value + return settings + + +def _divider(p: Network, style: str = "dash"): + char = _DIVIDER_CHARS.get(style, "-") + p._raw(b'\x1b\x61\x00') + if char: + p._raw(_gr(char * LINE_WIDTH + "\n")) + else: + p._raw(b'\n') + + +def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str: + """Build a dot-leader line ending with 'xN'. + line_width must reflect the effective width at the chosen font size + (double-width fonts halve the available char count to 24).""" + suffix = f"x{qty}" + available = line_width - len(name) - len(suffix) + if available < 2: + # Name alone is too long — put qty on same line with a single space + return f"{name} {suffix}" + dots = (". " * ((available // 2) + 1))[:available] + return f"{name}{dots}{suffix}" + + +def _apply_font(p: Network, size: int, bold: bool): + p._raw(bytes([0x1b, 0x21, size])) + p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00') + + +def _reset_font(p: Network): + p._raw(b'\x1b\x21\x00') + p._raw(b'\x1b\x45\x00') + + +def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool, + align: bytes = b'\x1b\x61\x00'): + """Apply font, optionally capitalize, print text + newline, reset font.""" + p._raw(align) + _apply_font(p, size, bold) + out = text.upper() if caps else text + _raw_text(p, out + "\n") + _reset_font(p) + + +def _greek_date(dt: datetime.datetime) -> str: + """Return date/time string in Greek format: HH:MM DD-MM-YYYY""" + return dt.strftime("%H:%M %d-%m-%Y") + + +def check_printer(ip: str, port: int) -> bool: + """Quick TCP connect check — no data sent.""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(2) + s.connect((ip, port)) + s.close() + return True + except OSError: + return False + + +def is_spoof_mode() -> bool: + """Stateless check — opens its own DB session. For use outside route_and_print.""" + db = SessionLocal() + try: + return _is_spoof_mode(db) + finally: + db.close() + + +def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]: + if is_spoof_mode(): + logger.info("Spoof printing ON — dropping test print for %s", name) + return True, "" + try: + p = _get_printer(ip, port) + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, f"TEST — {name}\n") + p._raw(b'\x1b\x21\x00') + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + _raw_text(p, f"{now}\n") + p._raw(b'\n\n\n') + p.cut() + p.close() + return True, "" + except Exception as e: + logger.error("Test print failed for %s:%s — %s", ip, port, e) + return False, str(e) + + +def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]: + """Print a fake order using the current font/layout settings — for settings preview.""" + if _is_spoof_mode(db): + logger.info("Spoof printing ON — dropping test order print") + return True, "" + + # ── Fake data structures (no DB writes) ────────────────────────────────── + class _Table: + label = "O2" + number = 2 + + class _User: + nickname = "bonamin" + username = "bonamin" + + class _Order: + id = 99 + table = _Table() + opener = _User() + table_id = 2 + opened_by = 1 + notes = "Χωρις καψαλισμα παρακαλω" + + class _Item: + def __init__(self, product_id, quantity, selected_options, removed_ingredients, notes): + self.product_id = product_id + self.quantity = quantity + self.selected_options = selected_options + self.removed_ingredients = removed_ingredients + self.notes = notes + + import json as _json + + items = [ + # Item 1: Freddo Espresso — quick options + preference + note + _Item( + product_id=1001, + quantity=2, + selected_options=_json.dumps([ + {"name": "Διπλος", "price_delta": 0.5, "type": "quick"}, + {"name": "Εξτρα ζαχαρη", "price_delta": 0.0, "type": "quick"}, + {"name": "Παγωμενος", "price_delta": 0.0, "type": "quick"}, + {"name": "Γαλα", "price_delta": 0.0, "type": "pref"}, + {"name": "Βρωμης", "price_delta": 0.3, "type": "pref_sub"}, + ]), + removed_ingredients=None, + notes="Πολυ κρυο παρακαλω", + ), + # Item 2: Club Sandwich — extra with sub + removed ingredients + _Item( + product_id=1002, + quantity=1, + selected_options=_json.dumps([ + {"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"}, + {"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"}, + {"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"}, + {"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"}, + {"name": "Ψωμι", "price_delta": 0.0, "type": "pref"}, + {"name": "Σικαλεως", "price_delta": 0.0, "type": "pref_sub"}, + ]), + removed_ingredients=_json.dumps(["Ντοματα", "Μουσταρδα"]), + notes=None, + ), + # Item 3: Margherita — quick + extra + removed + _Item( + product_id=1003, + quantity=3, + selected_options=_json.dumps([ + {"name": "Well Done", "price_delta": 0.0, "type": "quick"}, + {"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"}, + {"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"}, + {"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"}, + ]), + removed_ingredients=_json.dumps(["Ελιες", "Κρεμμυδι"]), + notes=None, + ), + ] + + # Patch product lookup so _print_kitchen_ticket gets real names + _FAKE_NAMES = {1001: "Freddo Espresso", 1002: "Club Sandwich", 1003: "Margherita Pizza"} + + # Monkey-patch db.query for Product only inside this call + _orig_query = db.query + + class _FakeQuery: + def __init__(self, model): + self._model = model + self._filter_id = None + def filter(self, *args): + # extract id from the filter expression value + for arg in args: + try: + self._filter_id = arg.right.value + except Exception: + pass + return self + def first(self): + if self._model.__name__ == "Product" and self._filter_id in _FAKE_NAMES: + class _P: + name = _FAKE_NAMES[self._filter_id] + return _P() + return _orig_query(self._model).filter(self._model.id == self._filter_id).first() + + class _PatchedDB: + def query(self, model): + from models.product import Product as _Product + if model is _Product: + return _FakeQuery(model) + return _orig_query(model) + # delegate everything else to real db + def __getattr__(self, name): + return getattr(db, name) + + try: + p = _get_printer(ip, port) + _print_kitchen_ticket(p, _Order(), items, _PatchedDB()) + p.close() + return True, "" + except Exception as e: + logger.error("Test order print failed for %s:%s — %s", ip, port, e) + return False, str(e) + + +# ── Receipt formatting ─────────────────────────────────────────────────────── + +def _parse_options(item: OrderItem) -> dict: + """ + Parse selected_options JSON into grouped dict: + { 'quick': [(name, qty)], 'pref': [(name, sub|None)], + 'extra': [(name, sub|None, qty)], 'unknown': [name] } + Falls back gracefully when type tags are absent (old data). + """ + result = {"quick": [], "pref": [], "extra": [], "unknown": []} + if not item.selected_options: + return result + + try: + raw = json.loads(item.selected_options) + except (json.JSONDecodeError, TypeError): + return result + + if not isinstance(raw, list): + return result + + i = 0 + while i < len(raw): + entry = raw[i] + if not isinstance(entry, dict): + i += 1 + continue + name = entry.get("name") or "" + etype = entry.get("type") + + # Peek at next entry to collect sub-choice + sub = None + if i + 1 < len(raw): + nxt = raw[i + 1] + if isinstance(nxt, dict) and nxt.get("type") in ("pref_sub", "extra_sub"): + sub = nxt.get("name") or "" + i += 1 # consume sub + + if etype == "quick": + # Collapse repeated quick entries into a single (name, qty) tuple + existing = next((q for q in result["quick"] if q[0] == name), None) + if existing: + result["quick"][result["quick"].index(existing)] = (name, existing[1] + 1) + else: + result["quick"].append((name, 1)) + elif etype == "pref": + result["pref"].append((name, sub)) + elif etype == "extra": + # Collapse repeated extra entries (same name+sub) → (name, sub, qty) + existing = next((e for e in result["extra"] if e[0] == name and e[1] == sub), None) + if existing: + result["extra"][result["extra"].index(existing)] = (name, sub, existing[2] + 1) + else: + result["extra"].append((name, sub, 1)) + else: + # Legacy data without type tag — treat as unknown, display plainly + if name: + result["unknown"].append(name + (f" · {sub}" if sub else "")) + + i += 1 + + return result + + +def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session): + cfg = _load_print_settings(db) + mode = cfg.get("print.ticket_mode", "detailed") + div = cfg.get("print.divider_style", "dash") + compact = (mode == "compact") + + sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"]) + sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"]) + sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"]) + sz_qk, b_qk, c_qk = _decode_font(cfg["print.font_quick"]) + sz_pr, b_pr, c_pr = _decode_font(cfg["print.font_pref"]) + sz_ex, b_ex, c_ex = _decode_font(cfg["print.font_extra"]) + sz_ing, b_ing, c_ing = _decode_font(cfg["print.font_ingredient"]) + sz_note, b_note, c_note = _decode_font(cfg["print.font_item_note"]) + sz_onote,b_onote,c_onote= _decode_font(cfg["print.font_order_note"]) + + # Resolve display names + table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id) + waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by) + now_str = _greek_date(datetime.datetime.now()) + + # ── COMPACT header — single line ──────────────────────────────────────── + if compact: + p._raw(b'\x1b\x61\x00') + _apply_font(p, sz_ord, b_ord) + header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}" + _raw_text(p, (header.upper() if c_ord else header) + "\n") + _reset_font(p) + _divider(p, div) + + # ── DETAILED header ────────────────────────────────────────────────────── + else: + _print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord, + align=b'\x1b\x61\x01') + _divider(p, div) + p._raw(b'\x1b\x61\x00') + _apply_font(p, sz_meta, b_meta) + _raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n") + _raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n") + _raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n") + _reset_font(p) + _divider(p, div) + + # ── Items ──────────────────────────────────────────────────────────────── + # Double-width fonts halve the effective character width + item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH + + for item in items: + product = db.query(Product).filter(Product.id == item.product_id).first() + raw_name = product.name if product else f"Product #{item.product_id}" + item_name = raw_name.upper() if c_item else raw_name + + p._raw(b'\x1b\x61\x00') + _apply_font(p, sz_item, b_item) + _raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n") + _reset_font(p) + + opts = _parse_options(item) + + # Quick options (* marker) + if opts["quick"]: + if compact: + parts = [] + for name, qty in opts["quick"]: + n = name.upper() if c_qk else name + parts.append(f"{n} x{qty}" if qty > 1 else n) + _apply_font(p, sz_qk, b_qk) + _raw_text(p, "* " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, qty in opts["quick"]: + n = name.upper() if c_qk else name + line = f"* {n} x{qty}" if qty > 1 else f"* {n}" + _apply_font(p, sz_qk, b_qk) + _raw_text(p, line + "\n") + _reset_font(p) + + # Preferences (> marker) + if opts["pref"]: + if compact: + parts = [] + for name, sub in opts["pref"]: + n = name.upper() if c_pr else name + s = (sub.upper() if c_pr else sub) if sub else None + parts.append(f"{n} · {s}" if s else n) + _apply_font(p, sz_pr, b_pr) + _raw_text(p, "> " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, sub in opts["pref"]: + n = name.upper() if c_pr else name + s = (sub.upper() if c_pr else sub) if sub else None + line = f"> {n} · {s}" if s else f"> {n}" + _apply_font(p, sz_pr, b_pr) + _raw_text(p, line + "\n") + _reset_font(p) + + # Extras (+ marker) + if opts["extra"]: + if compact: + parts = [] + for name, sub, qty in opts["extra"]: + n = name.upper() if c_ex else name + s = (sub.upper() if c_ex else sub) if sub else None + part = f"{n} · {s}" if s else n + if qty > 1: + part += f" · x{qty}" + parts.append(part) + _apply_font(p, sz_ex, b_ex) + _raw_text(p, "+ " + " | ".join(parts) + "\n") + _reset_font(p) + else: + for name, sub, qty in opts["extra"]: + n = name.upper() if c_ex else name + s = (sub.upper() if c_ex else sub) if sub else None + line = f"+ {n}" + if s: + line += f" · {s}" + if qty > 1: + line += f" · x{qty}" + _apply_font(p, sz_ex, b_ex) + _raw_text(p, line + "\n") + _reset_font(p) + + # Legacy untagged options + for entry in opts["unknown"]: + _apply_font(p, sz_ex, b_ex) + _raw_text(p, f"+ {entry}\n") + _reset_font(p) + + # Removed ingredients (- marker) + if item.removed_ingredients: + try: + removed = json.loads(item.removed_ingredients) + if removed: + names = [n.upper() if c_ing else n for n in removed] + joined = " · ".join(names) + _apply_font(p, sz_ing, b_ing) + _raw_text(p, f"- ΧΩΡΙΣ: {joined}\n") + _reset_font(p) + except (json.JSONDecodeError, TypeError): + pass + + # Per-item note + if item.notes: + note_text = item.notes.upper() if c_note else item.notes + _apply_font(p, sz_note, b_note) + if compact: + _raw_text(p, f"! {note_text}\n") + else: + _raw_text(p, f"\n(!) {note_text}\n\n") + _reset_font(p) + + # Blank line between items in detailed mode + if not compact: + p._raw(b'\n') + + _divider(p, div) + + # Order-level notes + if order.notes: + note_text = order.notes.upper() if c_onote else order.notes + _apply_font(p, sz_onote, b_onote) + _raw_text(p, f"Σημ: {note_text}\n") + _reset_font(p) + if not compact: + _divider(p, div) + + # Footer (detailed only) + if not compact: + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, "Τελος Παραγγελιας\n") + p._raw(b'\x1b\x21\x00') + + p._raw(b'\n\n\n') + p.cut() + + +# ── On-demand report / receipt prints ──────────────────────────────────────── + +def print_waiter_report(ip: str, port: int, report: dict, mode: str): + """Print a waiter shift/period report. mode='simple'|'extensive'.""" + if is_spoof_mode(): + logger.info("Spoof printing ON — dropping waiter report print") + return + try: + p = _get_printer(ip, port) + + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, "ΑΝΑΦΟΡΑ ΣΕΡΒΙΤΟΡΟΥ\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + p._raw(b'\x1b\x61\x00') + p._raw(b'\x1b\x21\x10') + _raw_text(p, f"Σερβιτορος: {report['waiter_name']}\n") + _raw_text(p, f"Απο: {report['from_dt']}\n") + _raw_text(p, f"Εως: {report['to_dt']}\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + p._raw(b'\x1b\x21\x10') + _raw_text(p, f"Παραγγελιες: {report['orders']}\n") + _raw_text(p, f"Αντικειμενα: {report['items']}\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n") + p._raw(b'\x1b\x21\x00') + + if mode == "extensive" and report.get("order_data"): + _divider(p) + p._raw(b'\x1b\x61\x00') + _raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n") + _divider(p) + for od in report["order_data"]: + # Build right-aligned total: "HH:MM - HH:MM - TABLE . . . 9.99e" + time_open = od.get("time_open", "") + time_close = od.get("time_close", "") + table = od["table"] + value = f"{od['total']:.2f}e" + times_part = f"{time_open} - {time_close}" if time_close else time_open + prefix = f"{times_part} - {table}" + gap = LINE_WIDTH - len(prefix) - len(value) + if gap < 3: + line = f"{prefix} {value}" + else: + dots = (". " * ((gap // 2) + 1))[:gap] + line = f"{prefix}{dots}{value}" + _raw_text(p, line + "\n") + + p._raw(b'\n\n\n') + p.cut() + p.close() + except Exception as e: + logger.error("print_waiter_report failed for %s:%s — %s", ip, port, e) + + +def print_printer_report(ip: str, port: int, report: dict, mode: str): + """Print a per-printer totals report. mode='simple'|'extensive'.""" + if is_spoof_mode(): + logger.info("Spoof printing ON — dropping printer report print") + return + try: + p = _get_printer(ip, port) + + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, "ΑΝΑΦΟΡΑ ΕΚΤΥΠΩΤΗ\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + p._raw(b'\x1b\x61\x00') + p._raw(b'\x1b\x21\x10') + _raw_text(p, f"Εκτυπωτης: {report['printer_name']}\n") + _raw_text(p, f"Απο: {report['from_dt']}\n") + _raw_text(p, f"Εως: {report['to_dt']}\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + p._raw(b'\x1b\x21\x10') + _raw_text(p, f"Εργασιες εκτ.: {report['print_jobs']}\n") + _raw_text(p, f"Παραγγελιες: {report['orders']}\n") + _raw_text(p, f"Αντικειμενα: {report['items']}\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n") + p._raw(b'\x1b\x21\x00') + + if mode == "extensive" and report.get("order_data"): + _divider(p) + p._raw(b'\x1b\x61\x00') + _raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n") + _divider(p) + for od in report["order_data"]: + # Header line: "HH:MM - TABLE . . . . 9.99e" + prefix = f"{od['time']} - {od['table']}" + value = f"{od['total']:.2f}e" + gap = LINE_WIDTH - len(prefix) - len(value) + if gap < 3: + header_line = f"{prefix} {value}" + else: + dots = (". " * ((gap // 2) + 1))[:gap] + header_line = f"{prefix}{dots}{value}" + p._raw(b'\x1b\x45\x01') + _raw_text(p, header_line + "\n") + p._raw(b'\x1b\x45\x00') + # Indented items + for item in od.get("items", []): + _raw_text(p, f" {item['quantity']} x {item['name']}\n") + + p._raw(b'\n\n\n') + p.cut() + p.close() + except Exception as e: + logger.error("print_printer_report failed for %s:%s — %s", ip, port, e) + + +def print_order_receipt(ip: str, port: int, receipt: dict): + """Print a manager-triggered order receipt.""" + if is_spoof_mode(): + logger.info("Spoof printing ON — dropping order receipt print") + return + try: + p = _get_printer(ip, port) + + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + p._raw(b'\x1b\x61\x00') + p._raw(b'\x1b\x21\x10') + _raw_text(p, f"Τραπεζι: {receipt['table_name']}\n") + _raw_text(p, f"Σερβιτορος: {receipt['waiter_name']}\n") + _raw_text(p, f"Ανοιχτηκε: {receipt['opened_at']}\n") + if receipt.get("closed_at"): + _raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + for item in receipt.get("items", []): + p._raw(b'\x1b\x21\x10') + _raw_text(p, _item_line(item["name"], item["quantity"]) + "\n") + p._raw(b'\x1b\x21\x00') + _raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n") + + _divider(p) + + if receipt.get("notes"): + p._raw(b'\x1b\x21\x10') + _raw_text(p, f"Σημ: {receipt['notes']}\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, f"ΣΥΝΟΛΟ: {receipt['total']:.2f}e\n") + p._raw(b'\x1b\x21\x00') + + p._raw(b'\n\n\n') + p.cut() + p.close() + except Exception as e: + logger.error("print_order_receipt failed for %s:%s — %s", ip, port, e) + + +def print_order_synopsis(ip: str, port: int, synopsis: dict): + """Print a waiter-triggered order synopsis (not a kitchen ticket).""" + if is_spoof_mode(): + logger.info("Spoof printing ON — dropping order synopsis print") + return + try: + p = _get_printer(ip, port) + + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, "ΣΥΝΟΨΗ ΠΑΡΑΓΓΕΛΙΑΣ\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + p._raw(b'\x1b\x61\x00') + p._raw(b'\x1b\x21\x10') + _raw_text(p, f"Τραπεζι: {synopsis['table_name']}\n") + _raw_text(p, f"Σερβιτορος: {synopsis['waiter_name']}\n") + _raw_text(p, f"Ωρα: {synopsis['opened_at']}\n") + p._raw(b'\x1b\x21\x00') + _divider(p) + + paid_items = [i for i in synopsis.get("items", []) if i["status"] == "paid"] + active_items = [i for i in synopsis.get("items", []) if i["status"] == "active"] + + if active_items: + p._raw(b'\x1b\x21\x10') + _raw_text(p, "ΕΚΚΡΕΜΗ:\n") + p._raw(b'\x1b\x21\x00') + for item in active_items: + _raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n") + _raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n") + _divider(p) + + if paid_items: + p._raw(b'\x1b\x21\x10') + _raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n") + p._raw(b'\x1b\x21\x00') + for item in paid_items: + _raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n") + _raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n") + _divider(p) + + p._raw(b'\x1b\x61\x01') + p._raw(b'\x1b\x21\x30') + _raw_text(p, f"ΣΥΝΟΛΟ: {synopsis['total']:.2f}e\n") + if synopsis.get('paid_total', 0) > 0: + p._raw(b'\x1b\x21\x10') + _raw_text(p, f"Πληρωμενο: {synopsis['paid_total']:.2f}e\n") + _raw_text(p, f"Εκκρεμει: {synopsis['remaining']:.2f}e\n") + p._raw(b'\x1b\x21\x00') + + p._raw(b'\n\n\n') + p.cut() + p.close() + except Exception as e: + logger.error("print_order_synopsis failed for %s:%s — %s", ip, port, e) + + +# ── Routing logic ──────────────────────────────────────────────────────────── + +def route_and_print(order_id: int, item_ids: List[int]): + """ + Background task: group items by printer zone, send to each printer. + Printer failures are logged but never raise — order is already saved. + """ + db: Session = SessionLocal() + try: + _do_route_and_print(order_id, item_ids, db) + except Exception as e: + logger.exception("Unexpected error in route_and_print for order %s: %s", order_id, e) + finally: + db.close() + + +def route_and_print_sync(order_id: int, item_ids: List[int], db: Session) -> List[dict]: + """ + Synchronous variant used when the caller needs print results. + Returns a list of per-printer result dicts: + { printer_name, success, error } + """ + return _do_route_and_print(order_id, item_ids, db) + + +def _is_spoof_mode(db: Session) -> bool: + row = db.query(PosSettings).filter(PosSettings.key == "dev.spoof_printing").first() + return row is not None and row.value == "true" + + +def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List[dict]: + if _is_spoof_mode(db): + logger.info("Spoof printing ON — dropping print job for order %s", order_id) + for item_id in item_ids: + item = db.query(OrderItem).filter(OrderItem.id == item_id).first() + if item: + item.printed = True + db.commit() + return [{"printer_name": "spoof", "success": True, "error": None}] + + results = [] + + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + logger.error("route_and_print: order %s not found", order_id) + return results + + items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all() + + # Group items by printer zone + zone_map: dict[int, List[OrderItem]] = {} + unzoned: List[OrderItem] = [] + for item in items: + product = db.query(Product).filter(Product.id == item.product_id).first() + if product and product.printer_zone_id: + zone_map.setdefault(product.printer_zone_id, []).append(item) + else: + unzoned.append(item) + + if unzoned: + logger.warning("order %s has %d item(s) with no printer zone — skipped", order_id, len(unzoned)) + + for printer_id, zone_items in zone_map.items(): + printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first() + if not printer: + logger.warning("Printer %s not found or inactive", printer_id) + results.append({"printer_name": f"#{printer_id}", "success": False, "error": "Printer not found or inactive"}) + continue + + success = False + error_msg = None + try: + p = _get_printer(printer.ip_address, printer.port) + _print_kitchen_ticket(p, order, zone_items, db) + p.close() + success = True + for item in zone_items: + item.printed = True + db.commit() + except Exception as e: + error_msg = str(e) + logger.error("Print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e) + + log = PrintLog( + order_id=order_id, + printer_id=printer_id, + item_ids=json.dumps([i.id for i in zone_items]), + success=success, + error_message=error_msg, + ) + db.add(log) + db.commit() + + results.append({"printer_name": printer.name, "success": success, "error": error_msg}) + + return results diff --git a/local_backend/services/sse_bus.py b/local_backend/services/sse_bus.py new file mode 100644 index 0000000..6680f6c --- /dev/null +++ b/local_backend/services/sse_bus.py @@ -0,0 +1,84 @@ +""" +SSE Event Bus — in-memory broadcaster for Server-Sent Events. + +All routers import `broadcast_sync()` to push events from sync routes. +The SSE endpoint imports `subscribe()` / `unsubscribe()` to manage per-client queues. + +Event shape (JSON-serialisable dict): + { "type": "", "data": { ... } } + +Supported event types: + order_updated — order created / item added / transferred / merged + order_paid — items paid on an order + order_closed — order closed or cancelled + table_list_changed — table added/removed + table_flags_changed — flags set/cleared on a table + message_sent — new staff message (targeted or broadcast) + shift_changed — shift started / ended by manager + business_day_changed — business day opened / closed +""" + +import asyncio +import json +from typing import Dict, Set + +# Captured once at startup by init_loop() called from lifespan. +# Sync route threads use this to schedule coroutines safely. +_main_loop: asyncio.AbstractEventLoop | None = None + +# waiter_id → set of asyncio.Queue (one per SSE connection for that user) +_queues: Dict[int, Set[asyncio.Queue]] = {} + + +def init_loop(loop: asyncio.AbstractEventLoop) -> None: + """Call once from the FastAPI lifespan (async context) to capture the event loop.""" + global _main_loop + _main_loop = loop + + +async def subscribe(user_id: int) -> asyncio.Queue: + q: asyncio.Queue = asyncio.Queue(maxsize=256) + if user_id not in _queues: + _queues[user_id] = set() + _queues[user_id].add(q) + return q + + +async def unsubscribe(user_id: int, q: asyncio.Queue) -> None: + if user_id in _queues: + _queues[user_id].discard(q) + if not _queues[user_id]: + del _queues[user_id] + + +def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None: + """ + Fire-and-forget broadcast from a synchronous FastAPI route (thread-pool worker). + Uses call_soon_threadsafe so the coroutine runs on the main event loop, not the thread. + """ + if _main_loop is None: + return + _main_loop.call_soon_threadsafe( + _main_loop.create_task, + broadcast(event_type, data, user_ids=user_ids), + ) + + +async def broadcast(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None: + """ + Push an event to connected clients. + user_ids=None → broadcast to ALL connected users + user_ids=[...] → send only to those specific user IDs + """ + payload = json.dumps({"type": event_type, "data": data}) + targets = ( + {uid: qs for uid, qs in _queues.items() if uid in user_ids} + if user_ids is not None + else dict(_queues) + ) + for qs in targets.values(): + for q in list(qs): + try: + q.put_nowait(payload) + except asyncio.QueueFull: + pass # slow client — drop rather than block diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..505d17d Binary files /dev/null and b/logo.png differ diff --git a/manager_dashboard/.dockerignore b/manager_dashboard/.dockerignore new file mode 100644 index 0000000..9c97bbd --- /dev/null +++ b/manager_dashboard/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/manager_dashboard/Dockerfile b/manager_dashboard/Dockerfile new file mode 100644 index 0000000..1d40503 --- /dev/null +++ b/manager_dashboard/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-slim AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/manager_dashboard/index.html b/manager_dashboard/index.html new file mode 100644 index 0000000..9212436 --- /dev/null +++ b/manager_dashboard/index.html @@ -0,0 +1,15 @@ + + + + + + POS Manager + + + + + +
+ + + diff --git a/manager_dashboard/nginx.conf b/manager_dashboard/nginx.conf new file mode 100644 index 0000000..281b57e --- /dev/null +++ b/manager_dashboard/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 3600; + } + + location /static/ { + proxy_pass http://backend:8000/static/; + proxy_set_header Host $host; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/manager_dashboard/package-lock.json b/manager_dashboard/package-lock.json new file mode 100644 index 0000000..bdbda83 --- /dev/null +++ b/manager_dashboard/package-lock.json @@ -0,0 +1,3564 @@ +{ + "name": "manager-dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "manager-dashboard", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "axios": "^1.7.9", + "lucide-react": "^1.14.0", + "qrcode.react": "^4.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.4.1", + "react-router-dom": "^6.28.0", + "recharts": "^3.8.1", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "vite": "^6.0.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.7", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.7.tgz", + "integrity": "sha512-LFVFtAROHcDy1er5UI6nodRFnZ2SgdCXhfNSI+DpObO8N7Pur/muBGsjzH5wpnFHCYhYVQxZskCkV4koQ//3/Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", + "integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", + "integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.99.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", + "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/manager_dashboard/package.json b/manager_dashboard/package.json new file mode 100644 index 0000000..ea743ac --- /dev/null +++ b/manager_dashboard/package.json @@ -0,0 +1,32 @@ +{ + "name": "manager-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.0", + "axios": "^1.7.9", + "lucide-react": "^1.14.0", + "qrcode.react": "^4.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.4.1", + "react-router-dom": "^6.28.0", + "recharts": "^3.8.1", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "vite": "^6.0.5" + } +} diff --git a/manager_dashboard/postcss.config.js b/manager_dashboard/postcss.config.js new file mode 100644 index 0000000..be56e0e --- /dev/null +++ b/manager_dashboard/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} }, +} diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx new file mode 100644 index 0000000..0d52837 --- /dev/null +++ b/manager_dashboard/src/App.jsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react' +import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom' +import useAuthStore from './store/authStore' +import AppLayout from './layouts/AppLayout' +import LoginPage from './pages/LoginPage' +import SetupWizard from './pages/SetupWizard' +import OperationsPage from './pages/OperationsPage' +import TablesPage from './pages/TablesPage' +import OrderDetailPage from './pages/OrderDetailPage' +import ManagementPage from './pages/ManagementPage' +import ReportsPage from './pages/reports/ReportsPage' +import SettingsPage from './pages/Settings/SettingsPage' +import client from './api/client' + +function Spinner() { + return ( +
+
+
+ ) +} + +// Rehydrates user from stored token before rendering any routes. +// Prevents the flicker where a valid token causes a redirect to /login on refresh. +function AuthRehydrator({ children }) { + const { token, user, rehydrate, logout } = useAuthStore() + const [ready, setReady] = useState(false) + + useEffect(() => { + if (token && !user) { + client.get('/api/auth/me') + .then(r => { rehydrate(r.data, token) }) + .catch(() => { logout() }) + .finally(() => setReady(true)) + } else { + setReady(true) + } + }, []) // intentionally runs once on mount + + if (!ready) return + return children +} + +function RequireAuth({ children }) { + const token = useAuthStore(s => s.token) + return token ? children : +} + +// Checks /api/setup/status on mount and redirects to /setup if no managers exist. +function SetupGuard({ children }) { + const [checked, setChecked] = useState(false) + const navigate = useNavigate() + + useEffect(() => { + client.get('/api/setup/status') + .then(({ data }) => { + if (data.needs_setup) navigate('/setup', { replace: true }) + }) + .catch(() => { + // Backend unreachable — proceed, login will surface the error. + }) + .finally(() => setChecked(true)) + }, [navigate]) + + if (!checked) return + return children +} + +export default function App() { + return ( + + + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} diff --git a/manager_dashboard/src/api/client.js b/manager_dashboard/src/api/client.js new file mode 100644 index 0000000..6e5c8f7 --- /dev/null +++ b/manager_dashboard/src/api/client.js @@ -0,0 +1,25 @@ +import axios from 'axios' + +const client = axios.create({ baseURL: '' }) + +client.interceptors.request.use(config => { + const token = localStorage.getItem('manager_token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +client.interceptors.response.use( + res => res, + err => { + if (err.response?.status === 401) { + // On hard 401 (expired/invalid token) force a full logout + localStorage.removeItem('manager_token') + localStorage.removeItem('manager_username') + localStorage.removeItem('manager_lock_timeout') + window.location.href = '/login' + } + return Promise.reject(err) + } +) + +export default client diff --git a/manager_dashboard/src/components/ConfirmModal.jsx b/manager_dashboard/src/components/ConfirmModal.jsx new file mode 100644 index 0000000..60b6365 --- /dev/null +++ b/manager_dashboard/src/components/ConfirmModal.jsx @@ -0,0 +1,14 @@ +export default function ConfirmModal({ title, message, confirmLabel = 'Επιβεβαίωση', confirmClass = 'btn-danger', onConfirm, onCancel }) { + return ( +
+
+

{title}

+ {message &&

{message}

} +
+ + +
+
+
+ ) +} diff --git a/manager_dashboard/src/components/DateInput.jsx b/manager_dashboard/src/components/DateInput.jsx new file mode 100644 index 0000000..c213ec3 --- /dev/null +++ b/manager_dashboard/src/components/DateInput.jsx @@ -0,0 +1,79 @@ +/** + * DateInput / DateTimeInput + * + * Native date pickers display in the OS/browser locale (MM/DD/YYYY on en-US). + * These wrappers overlay the native input with a visible DD/MM/YYYY display + * while keeping the full native picker UX (click, keyboard, mobile wheel). + * + * Props mirror a plain : value (YYYY-MM-DD or YYYY-MM-DDTHH:MM), + * onChange (receives the same synthetic event), className. + */ +import { useRef } from 'react' + +function formatDateGR(value) { + // value is "YYYY-MM-DD" + if (!value) return '' + const [y, m, d] = value.split('-') + if (!y || !m || !d) return value + return `${d}/${m}/${y}` +} + +function formatDateTimeGR(value) { + // value is "YYYY-MM-DDTHH:MM" + if (!value) return '' + const [datePart, timePart] = value.split('T') + if (!datePart) return value + const [y, m, d] = datePart.split('-') + if (!y || !m || !d) return value + return `${d}/${m}/${y}${timePart ? ' ' + timePart : ''}` +} + +export function DateInput({ value, onChange, className = '', ...rest }) { + const ref = useRef(null) + + return ( +
ref.current?.showPicker?.()} + > + {/* Visible display */} +
+ {value ? formatDateGR(value) : ΗΗ/ΜΜ/ΕΕΕΕ} +
+ {/* Native input — invisible but functional (provides the picker) */} + +
+ ) +} + +export function DateTimeInput({ value, onChange, className = '', ...rest }) { + const ref = useRef(null) + + return ( +
ref.current?.showPicker?.()} + > +
+ {value ? formatDateTimeGR(value) : ΗΗ/ΜΜ/ΕΕΕΕ ΩΩ:ΛΛ} +
+ +
+ ) +} diff --git a/manager_dashboard/src/components/EditProfileModal.jsx b/manager_dashboard/src/components/EditProfileModal.jsx new file mode 100644 index 0000000..3f2bdf6 --- /dev/null +++ b/manager_dashboard/src/components/EditProfileModal.jsx @@ -0,0 +1,166 @@ +import { useState } from 'react' +import Modal from '../ui/Modal' +import Button from '../ui/Button' +import { LabelledInput } from '../ui/Input' +import client from '../api/client' +import useAuthStore from '../store/authStore' + +export default function EditProfileModal({ onClose }) { + const { user, updateUser } = useAuthStore() + + const [fullName, setFullName] = useState(user?.full_name || '') + const [username, setUsername] = useState(user?.username || '') + + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + + const [newPin, setNewPin] = useState('') + const [confirmPin, setConfirmPin] = useState('') + + const [error, setError] = useState('') + const [saving, setSaving] = useState(false) + + const passwordTouched = currentPassword || newPassword || confirmPassword + const pinTouched = newPin || confirmPin + + function validate() { + if (passwordTouched) { + if (!currentPassword) return 'Enter your current password.' + if (!newPassword) return 'Enter a new password.' + if (newPassword.length < 6) return 'New password must be at least 6 characters.' + if (newPassword !== confirmPassword) return 'New passwords do not match.' + } + if (pinTouched) { + if (newPin.length < 4) return 'PIN must be 4 digits.' + if (newPin !== confirmPin) return 'PINs do not match.' + } + if (!username.trim()) return 'Username cannot be empty.' + return null + } + + async function handleSave() { + const validationError = validate() + if (validationError) { setError(validationError); return } + + const body = {} + if (fullName !== (user?.full_name || '')) body.full_name = fullName + if (username !== user?.username) body.username = username + if (passwordTouched) { + body.current_password = currentPassword + body.new_password = newPassword + } + if (pinTouched) { + body.new_pin = newPin + } + + if (Object.keys(body).length === 0) { onClose(); return } + + setError('') + setSaving(true) + try { + const { data } = await client.patch('/api/auth/me', body) + updateUser(data) + onClose() + } catch (err) { + const detail = err.response?.data?.detail + setError(typeof detail === 'string' ? detail : 'Failed to save changes.') + } finally { + setSaving(false) + } + } + + function onlyDigits(val, max) { + return val.replace(/\D/g, '').slice(0, max) + } + + return ( + +
+ +
+ setFullName(e.target.value)} + placeholder="e.g. Maria Papadopoulou" + /> + setUsername(e.target.value)} + autoComplete="off" + /> +
+ +
+ +
+

+ Change Password (leave blank to keep current) +

+ setCurrentPassword(e.target.value)} + autoComplete="current-password" + /> + setNewPassword(e.target.value)} + autoComplete="new-password" + /> + setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> +
+ +
+ +
+

+ Change PIN (leave blank to keep current) +

+
+ setNewPin(onlyDigits(e.target.value, 4))} + placeholder="••••" + className="flex-1" + /> + setConfirmPin(onlyDigits(e.target.value, 4))} + placeholder="••••" + className="flex-1" + /> +
+
+ + {error && ( +

{error}

+ )} +
+ + + + + + + ) +} diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx new file mode 100644 index 0000000..5adb490 --- /dev/null +++ b/manager_dashboard/src/components/Sidebar.jsx @@ -0,0 +1,47 @@ +import { NavLink } from 'react-router-dom' +import { useState } from 'react' +import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react' + +const NAV = [ + { to: '/operations', icon: BarChart2, label: 'Διοίκηση' }, + { to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' }, + { to: '/reports', icon: ClipboardList, label: 'Αναφορές' }, + { to: '/management', icon: Package, label: 'Διαχείριση' }, + { to: '/settings', icon: Settings, label: 'Ρυθμίσεις' }, +] + +export default function Sidebar() { + const [collapsed, setCollapsed] = useState(false) + + return ( + + ) +} diff --git a/manager_dashboard/src/components/StatusBadge.jsx b/manager_dashboard/src/components/StatusBadge.jsx new file mode 100644 index 0000000..026d008 --- /dev/null +++ b/manager_dashboard/src/components/StatusBadge.jsx @@ -0,0 +1,18 @@ +const MAP = { + free: { label: 'Ελεύθερο', cls: 'bg-gray-100 text-gray-600' }, + open: { label: 'Ανοιχτό', cls: 'bg-green-100 text-green-700' }, + partially_paid: { label: 'Μερική πληρωμή', cls: 'bg-amber-100 text-amber-700' }, + paid: { label: 'Πληρώθηκε', cls: 'bg-blue-100 text-blue-700' }, + closed: { label: 'Κλειστό', cls: 'bg-gray-200 text-gray-500' }, + cancelled: { label: 'Ακυρώθηκε', cls: 'bg-red-100 text-red-600' }, + active: { label: 'Ενεργό', cls: 'bg-green-100 text-green-700' }, +} + +export default function StatusBadge({ status }) { + const { label, cls } = MAP[status] ?? { label: status, cls: 'bg-gray-100 text-gray-600' } + return ( + + {label} + + ) +} diff --git a/manager_dashboard/src/components/UserMenuButton.jsx b/manager_dashboard/src/components/UserMenuButton.jsx new file mode 100644 index 0000000..e43a9a1 --- /dev/null +++ b/manager_dashboard/src/components/UserMenuButton.jsx @@ -0,0 +1,56 @@ +import { useState, useEffect, useRef } from 'react' +import { ChevronDown, User, LogOut } from 'lucide-react' + +export default function UserMenuButton({ displayName, onEditProfile, onSignOut }) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + function handleOutside(e) { + if (ref.current && !ref.current.contains(e.target)) setOpen(false) + } + function handleEscape(e) { + if (e.key === 'Escape') setOpen(false) + } + if (open) { + document.addEventListener('mousedown', handleOutside) + document.addEventListener('keydown', handleEscape) + } + return () => { + document.removeEventListener('mousedown', handleOutside) + document.removeEventListener('keydown', handleEscape) + } + }, [open]) + + return ( +
+ + + {open && ( +
+ +
+ +
+ )} +
+ ) +} diff --git a/manager_dashboard/src/hooks/useLicenseStatus.js b/manager_dashboard/src/hooks/useLicenseStatus.js new file mode 100644 index 0000000..117831c --- /dev/null +++ b/manager_dashboard/src/hooks/useLicenseStatus.js @@ -0,0 +1,84 @@ +import { useQuery } from '@tanstack/react-query' +import client from '../api/client' + +/** + * Polls /api/system/status every 5 minutes to stay in sync with the backend + * heartbeat cycle. Returns a stable object so callers can destructure safely. + * + * Returned shape: + * licensed bool + * locked bool + * lock_pending bool + * lock_reason "admin" | "expired" | null + * expires_at string | null (ISO) + * days_until_expiry number | null (negative = expired) + * grace_expires_at string | null (ISO) + * grace_days_remaining number | null + * sync_failed bool + * + * Derived helpers: + * showExpiryWarning bool — true when 0 < days_until_expiry <= 5 + * inGracePeriod bool — true when expired but grace not yet over + * isBlocked bool — locked=true OR (unlicensed AND grace over) + */ +export default function useLicenseStatus() { + const { data } = useQuery({ + queryKey: ['license-status'], + queryFn: () => client.get('/api/system/status').then(r => r.data), + staleTime: 0, + refetchInterval: 5 * 60 * 1000, + refetchIntervalInBackground: true, + }) + + if (!data) { + return { + licensed: true, + locked: false, + lock_pending: false, + lock_reason: null, + expires_at: null, + days_until_expiry: null, + grace_expires_at: null, + grace_days_remaining: null, + sync_failed: false, + showExpiryWarning: false, + inGracePeriod: false, + isBlocked: false, + } + } + + const { + licensed = true, + locked = false, + lock_pending = false, + lock_reason = null, + expires_at = null, + days_until_expiry = null, + grace_expires_at = null, + grace_days_remaining = null, + sync_failed = false, + } = data + + const showExpiryWarning = + days_until_expiry != null && days_until_expiry >= 0 && days_until_expiry <= 5 + + const inGracePeriod = + days_until_expiry != null && days_until_expiry < 0 && licensed + + const isBlocked = locked || !licensed + + return { + licensed, + locked, + lock_pending, + lock_reason, + expires_at, + days_until_expiry, + grace_expires_at, + grace_days_remaining, + sync_failed, + showExpiryWarning, + inGracePeriod, + isBlocked, + } +} diff --git a/manager_dashboard/src/icons/add.svg b/manager_dashboard/src/icons/add.svg new file mode 100644 index 0000000..d95e595 --- /dev/null +++ b/manager_dashboard/src/icons/add.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/manager_dashboard/src/icons/delete.svg b/manager_dashboard/src/icons/delete.svg new file mode 100644 index 0000000..99d6013 --- /dev/null +++ b/manager_dashboard/src/icons/delete.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/manager_dashboard/src/icons/edit.svg b/manager_dashboard/src/icons/edit.svg new file mode 100644 index 0000000..e91aaa8 --- /dev/null +++ b/manager_dashboard/src/icons/edit.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/manager_dashboard/src/icons/move-down.svg b/manager_dashboard/src/icons/move-down.svg new file mode 100644 index 0000000..9d1a594 --- /dev/null +++ b/manager_dashboard/src/icons/move-down.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/manager_dashboard/src/icons/move-up.svg b/manager_dashboard/src/icons/move-up.svg new file mode 100644 index 0000000..94b0bf7 --- /dev/null +++ b/manager_dashboard/src/icons/move-up.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/manager_dashboard/src/index.css b/manager_dashboard/src/index.css new file mode 100644 index 0000000..324ea15 --- /dev/null +++ b/manager_dashboard/src/index.css @@ -0,0 +1,36 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-slate-50 text-slate-900 text-base antialiased; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center px-4 py-2.5 rounded-xl font-semibold text-sm transition-colors min-h-[44px] disabled:opacity-40 disabled:cursor-not-allowed; + } + .btn-primary { + @apply bg-primary-700 hover:bg-primary-800 text-white; + } + .btn-secondary { + @apply bg-gray-200 hover:bg-gray-300 text-gray-700; + } + .btn-danger { + @apply bg-red-600 hover:bg-red-700 text-white; + } + .btn-ghost { + @apply bg-transparent hover:bg-gray-100 text-gray-600; + } + .card { + @apply bg-white rounded-2xl shadow-sm border border-gray-100; + } + .input { + @apply w-full border border-gray-300 rounded-xl px-4 py-2.5 text-base focus:outline-none focus:ring-2 focus:ring-primary-600 disabled:bg-gray-50; + } + .label { + @apply block text-sm font-medium text-gray-700 mb-1; + } +} diff --git a/manager_dashboard/src/layouts/AppLayout.jsx b/manager_dashboard/src/layouts/AppLayout.jsx new file mode 100644 index 0000000..20907ed --- /dev/null +++ b/manager_dashboard/src/layouts/AppLayout.jsx @@ -0,0 +1,302 @@ +import { Outlet, useNavigate } from 'react-router-dom' +import { useState, useEffect, useRef, createContext, useContext } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Lock, AlertTriangle, ShieldAlert } from 'lucide-react' +import Sidebar from '../components/Sidebar' +import useAuthStore from '../store/authStore' +import client from '../api/client' +import UserMenuButton from '../components/UserMenuButton' +import EditProfileModal from '../components/EditProfileModal' +import useLicenseStatus from '../hooks/useLicenseStatus' + +export const LicenseContext = createContext(null) + +const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫'] + +// ─── License Banner ─────────────────────────────────────────────────────────── + +function LicenseBanner({ license }) { + const { lock_reason, locked, lock_pending, days_until_expiry, expires_at, + grace_days_remaining, showExpiryWarning, inGracePeriod, isBlocked } = license + + function fmtDate(iso) { + if (!iso) return '' + try { + return new Date(iso).toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + } catch { return iso } + } + + // Admin lock (deferred or enforced) + if (lock_reason === 'admin') { + return ( +
+ + {locked + ? 'Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.' + : 'Εκκρεμεί κλείδωμα από διαχειριστή — θα ενεργοποιηθεί μετά το κλείσιμο της τρέχουσας ημέρας.'} +
+ ) + } + + // License fully expired + grace over → blocked + if (isBlocked && lock_reason === 'expired') { + const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?' + return ( +
+ + Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη. +
+ ) + } + + // In grace period (expired but still allowed to operate) + if (inGracePeriod) { + const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?' + const remaining = grace_days_remaining ?? '?' + return ( +
+ + Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα. +
+ ) + } + + // Expiry warning (≤5 days remaining) + if (showExpiryWarning) { + const days = days_until_expiry + return ( +
+ + Η άδεια χρήσης λήγει σε {days} {days === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε έγκαιρα. +
+ ) + } + + return null +} + +// ─── Lock Screen — PIN only, always. No password ever. ──────────────────────── + +function LockScreen({ username, displayName, onUnlock, onLogout }) { + const [pin, setPin] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + function pressDigit(d) { + if (loading) return + if (d === '⌫') { setPin(p => p.slice(0, -1)); setError(''); return } + if (d === '') return + if (pin.length >= 6) return + setPin(p => p + d) + } + + async function submitPin(usedPin) { + if (usedPin.length < 4) return + setError('') + setLoading(true) + try { + const { data } = await client.post('/api/auth/login', { username, pin: usedPin }) + if (data.user.role !== 'manager' && data.user.role !== 'sysadmin') { + setError('Not a manager account.') + setPin('') + return + } + onUnlock(data.user, data.access_token) + } catch { + setError('Wrong PIN') + setPin('') + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (pin.length === 4) submitPin(pin) + }, [pin]) + + useEffect(() => { + function onKeyDown(e) { + if (loading) return + if (e.key >= '0' && e.key <= '9') pressDigit(e.key) + else if (e.key === 'Backspace') pressDigit('⌫') + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [pin, loading]) + + return ( +
+
+
+
+
+ +
+
+

Locked

+

{displayName}

+
+ +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ {DIGITS.map((d, i) => ( + + ))} +
+
+ + {error &&

{error}

} + {loading &&

Verifying…

} + + +
+
+ ) +} + +// ─── AppLayout ──────────────────────────────────────────────────────────────── + +export default function AppLayout() { + const { user, savedUsername, logout, lock, unlock, locked } = useAuthStore() + const [clock, setClock] = useState(new Date()) + const [profileOpen, setProfileOpen] = useState(false) + const navigate = useNavigate() + const license = useLicenseStatus() + + const { data: securitySettings = null } = useQuery({ + queryKey: ['pos-settings'], + queryFn: () => client.get('/api/settings/').then(r => r.data), + staleTime: 10_000, + }) + + // Single ref object — updated every render so the interval always sees fresh values + const stateRef = useRef({}) + stateRef.current = { user, locked, securitySettings, logout, lock, navigate } + + const lastActivityRef = useRef(Date.now()) + + // ── Clock ────────────────────────────────────────────────────────────────── + useEffect(() => { + const id = setInterval(() => setClock(new Date()), 1000) + return () => clearInterval(id) + }, []) + + // ── Single long-lived interval — never restarts ──────────────────────────── + useEffect(() => { + function onActivity() { lastActivityRef.current = Date.now() } + + const EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'click'] + EVENTS.forEach(ev => window.addEventListener(ev, onActivity, { passive: true })) + + const id = setInterval(() => { + const { user, locked, securitySettings, logout, lock, navigate } = stateRef.current + const idle = (Date.now() - lastActivityRef.current) / 1000 + if (!user || !securitySettings) return + + const get = (key, fb) => securitySettings?.[key]?.value ?? fb + const autoLock = get('security.auto_lock', 'false') === 'true' + const autoLogout = get('security.auto_logout', 'false') === 'true' + if (!autoLock && !autoLogout) return + + const lockSecs = autoLock ? parseInt(get('security.auto_lock_seconds', '300'), 10) : Infinity + const logoutSecs = autoLogout ? parseInt(get('security.auto_logout_seconds', '1800'), 10) : Infinity + + // Auto-logout runs regardless of lock state + if (idle >= logoutSecs) { + logout() + navigate('/login', { replace: true, state: { manualLogout: true } }) + return + } + + // Auto-lock only fires when not already locked + if (!locked && idle >= lockSecs) { + lock() + } + }, 1_000) + + return () => { + clearInterval(id) + EVENTS.forEach(ev => window.removeEventListener(ev, onActivity)) + } + }, []) // runs once on mount, reads everything from stateRef + + // ── Handlers ────────────────────────────────────────────────────────────── + function handleLogout() { + logout() + navigate('/login', { replace: true, state: { manualLogout: true } }) + } + + function handleUnlock(u, t) { + unlock(u, t) + lastActivityRef.current = Date.now() + } + + const timeStr = clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' }) + const loginUsername = user?.username || savedUsername || '' + const displayName = user?.full_name || loginUsername + + return ( + +
+ {locked && loginUsername && ( + + )} + + +
+ +
+ {timeStr} +
+ + setProfileOpen(true)} + onSignOut={handleLogout} + /> + {profileOpen && setProfileOpen(false)} />} +
+
+
+ +
+
+
+
+ ) +} diff --git a/manager_dashboard/src/main.jsx b/manager_dashboard/src/main.jsx new file mode 100644 index 0000000..4d57aa5 --- /dev/null +++ b/manager_dashboard/src/main.jsx @@ -0,0 +1,21 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Toaster } from 'react-hot-toast' +import App from './App.jsx' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 1, staleTime: 10_000 }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + +) diff --git a/manager_dashboard/src/pages/DashboardTab.jsx b/manager_dashboard/src/pages/DashboardTab.jsx new file mode 100644 index 0000000..69b30e2 --- /dev/null +++ b/manager_dashboard/src/pages/DashboardTab.jsx @@ -0,0 +1,788 @@ +import { useState, useContext } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import toast from 'react-hot-toast' +import client from '../api/client' +import Button from '../ui/Button' +import { LicenseContext } from '../layouts/AppLayout' + +// ─── Business Day + Shift Management Panel ─────────────────────────────────── + +function fmtTime(iso) { + if (!iso) return '—' + return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' }) +} + +function fmtShiftDuration(iso) { + if (!iso) return '' + const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000) + if (mins < 60) return `${mins}λ` + const h = Math.floor(mins / 60); const m = mins % 60 + return m === 0 ? `${h}ω` : `${h}ω ${m}λ` +} + +function StartShiftModal({ waiters, onClose, onStart }) { + const [waiterId, setWaiterId] = useState('') + const [cash, setCash] = useState('') + const [busy, setBusy] = useState(false) + + async function submit() { + if (!waiterId) { toast.error('Επιλέξτε σερβιτόρο'); return } + setBusy(true) + try { + await onStart(Number(waiterId), cash ? parseFloat(cash) : null) + onClose() + } catch (e) { + toast.error(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας') + } finally { + setBusy(false) + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+

Έναρξη Βάρδιας

+ +
+
+ + +
+
+ + setCash(e.target.value)} + className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none" /> +
+
+ + +
+
+
+ ) +} + +function CloseConfirmModal({ details, onClose, onConfirm, busy }) { + const hasPendingPayments = details.partially_paid > 0 + + if (!hasPendingPayments) { + // All tables open but nothing owed — safe to close, just needs confirmation + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+

Κλείσιμο Ημέρας

+
+

+ {details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'} +

+

Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;

+
+
+ + +
+
+
+ ) + } + + // Some tables have unpaid items — revenue will be lost, needs hard warning + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+
+ ! +
+

Εκκρεμείς Πληρωμές

+
+
+

+ {details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'}, + από τα οποία {details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές. +

+

Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.

+
+
+ Επιλέξτε Ακύρωση για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα. +
+
+ + +
+
+
+ ) +} + +function BusinessDayPanel() { + const qc = useQueryClient() + const [showStartShift, setShowStartShift] = useState(false) + const [closeDetails, setCloseDetails] = useState(null) + const [forceClosing, setForceClosing] = useState(false) + const [licenseBlock, setLicenseBlock] = useState(null) + const license = useContext(LicenseContext) + + const { data: businessDay } = useQuery({ + queryKey: ['business-day'], + queryFn: () => client.get('/api/business-day/current').then(r => r.data), + refetchInterval: 15_000, + }) + + const { data: activeShifts = [] } = useQuery({ + queryKey: ['active-shifts'], + queryFn: () => client.get('/api/shifts/?active_only=true').then(r => r.data.shifts ?? []), + refetchInterval: 15_000, + }) + + const { data: allWaiters = [] } = useQuery({ + queryKey: ['waiters'], + queryFn: () => client.get('/api/waiters/').then(r => r.data), + staleTime: 60_000, + }) + + const waitersWithoutShift = allWaiters.filter( + w => w.role === 'waiter' && !activeShifts.some(s => s.waiter_id === w.id) + ) + + const openDayMut = useMutation({ + mutationFn: () => client.post('/api/business-day/open', {}), + onSuccess: () => { toast.success('Ημέρα ανοίχτηκε!'); qc.invalidateQueries({ queryKey: ['business-day'] }) }, + onError: (e) => { + const detail = e.response?.data?.detail + if (detail?.code === 'SYSTEM_LOCKED' || detail?.code === 'LICENSE_EXPIRED') { + setLicenseBlock({ code: detail.code, message: detail.message }) + } else { + toast.error(typeof detail === 'string' ? detail : 'Σφάλμα') + } + }, + }) + + function handleOpenDay() { + if (license?.isBlocked) { + setLicenseBlock({ + code: license.lock_reason === 'admin' ? 'SYSTEM_LOCKED' : 'LICENSE_EXPIRED', + message: license.lock_reason === 'admin' + ? 'Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.' + : 'Η άδεια χρήσης έχει λήξει. Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.', + }) + return + } + openDayMut.mutate() + } + + async function handleCloseDay(force = false) { + setForceClosing(force) + try { + await client.post('/api/business-day/close', { force }) + toast.success('Ημέρα έκλεισε!') + setCloseDetails(null) + qc.invalidateQueries({ queryKey: ['business-day'] }) + qc.invalidateQueries({ queryKey: ['active-shifts'] }) + qc.invalidateQueries({ queryKey: ['orders-active'] }) + } catch (e) { + const detail = e.response?.data?.detail + if (e.response?.status === 409 && detail?.open_orders) { + setCloseDetails(detail) + } else { + toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος') + } + } finally { + setForceClosing(false) + } + } + + async function handleEndShift(shiftId, waiterName) { + if (!window.confirm(`Να τελειώσει η βάρδια του ${waiterName};`)) return + try { + await client.post(`/api/shifts/manager/end/${shiftId}`, {}) + toast.success('Βάρδια έκλεισε') + qc.invalidateQueries({ queryKey: ['active-shifts'] }) + } catch (e) { + toast.error(e.response?.data?.detail || 'Σφάλμα') + } + } + + async function handleStartShift(waiterId, startingCash) { + await client.post('/api/shifts/manager/start', { waiter_id: waiterId, starting_cash: startingCash }) + toast.success('Βάρδια ξεκίνησε!') + qc.invalidateQueries({ queryKey: ['active-shifts'] }) + } + + const isOpen = !!businessDay + + return ( + <> +
+ {/* Header row */} +
+
+
+
+ + {isOpen ? 'Εστιατόριο Ανοιχτό' : 'Εστιατόριο Κλειστό'} + + {isOpen && businessDay?.opened_at && ( + + από {fmtTime(businessDay.opened_at)} + + )} +
+
+
+ {isOpen && waitersWithoutShift.length > 0 && ( + + )} + {isOpen ? ( + + ) : ( + + )} +
+
+ + {/* Active shifts */} + {isOpen && ( +
+ {activeShifts.length === 0 ? ( +

Κανένας σερβιτόρος σε βάρδια

+ ) : ( +
+ {activeShifts.map(s => ( +
+
+ {s.waiter_name} + {fmtTime(s.started_at)} · {fmtShiftDuration(s.started_at)} + {s.total_collected > 0 && ( + €{s.total_collected.toFixed(2)} + )} +
+ +
+ ))} +
+ )} +
+ )} +
+ + {showStartShift && ( + setShowStartShift(false)} + onStart={handleStartShift} + /> + )} + {closeDetails && ( + setCloseDetails(null)} + onConfirm={() => handleCloseDay(true)} + busy={forceClosing} + /> + )} + {licenseBlock && ( +
+
+
+
+ {licenseBlock.code === 'SYSTEM_LOCKED' ? '🔒' : '⚠️'} +
+
+

+ {licenseBlock.code === 'SYSTEM_LOCKED' ? 'Σύστημα Κλειδωμένο' : 'Άδεια Χρήσης Ληγμένη'} +

+

{licenseBlock.message}

+ +
+
+ )} + + ) +} + + +const FILTERS = ['all', 'open', 'partially_paid', 'free'] +const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' } + +// ─── Design tokens ──────────────────────────────────────────────────────────── +const COLORS = { + open: { + label: 'Ανοιχτό', + tint: '#eef7f0', tintStrong: '#d7ecdc', + accent: '#2f9e5e', ink: '#1f7042', + }, + partially_paid: { + label: 'Μερική πληρ.', + tint: '#f4eefb', tintStrong: '#e3d4f3', + accent: '#7a44c9', ink: '#57309a', + }, + free: { + label: 'Ελεύθερο', + tint: '#f4f4f2', tintStrong: '#dfe2e6', + accent: '#8a9099', ink: '#5a6169', + }, +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function formatEuro(n) { + return '€' + parseFloat(n).toFixed(2) +} + +function formatDuration(openedAt) { + const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000) + if (mins < 60) return `${mins}m` + const h = Math.floor(mins / 60) + const m = mins % 60 + return m === 0 ? `${h}h` : `${h}h ${m}m` +} + +function occupiedMinsFromDate(openedAt) { + return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000) +} + +function orderTotal(items = []) { + return items + .filter(i => i.status !== 'cancelled') + .reduce((s, i) => s + i.unit_price * i.quantity, 0) +} + +function avatarColor(name) { + const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775'] + let h = 0 + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0 + return palette[h % palette.length] +} + +function WaiterBubble({ waiter, size = 26 }) { + // waiter: { name, avatarUrl } + if (waiter.avatarUrl) { + return ( + {waiter.name} + ) + } + const parts = waiter.name.trim().split(' ') + const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase() + return ( +
{initials}
+ ) +} + +// ─── V1 Table Card ──────────────────────────────────────────────────────────── +function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, onClick }) { + const s = COLORS[status] || COLORS.free + const [hover, setHover] = useState(false) + const [pressed, setPressed] = useState(false) + + const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null + const showMulti = waiters.length >= 3 + + return ( + + ) +} + +// ─── Page ───────────────────────────────────────────────────────────────────── +export default function DashboardPage() { + const [filter, setFilter] = useState('all') + const [retryingId, setRetryingId] = useState(null) + const navigate = useNavigate() + const queryClient = useQueryClient() + + const { data: tables = [], isLoading: tablesLoading } = useQuery({ + queryKey: ['tables'], + queryFn: () => client.get('/api/tables/').then(r => r.data), + refetchInterval: 5_000, + }) + + const { data: orders = [], isLoading: ordersLoading } = useQuery({ + queryKey: ['orders-active'], + queryFn: () => client.get('/api/orders/').then(r => r.data), + refetchInterval: 5_000, + }) + + const { data: waiters = [] } = useQuery({ + queryKey: ['waiters'], + queryFn: () => client.get('/api/waiters/').then(r => r.data), + staleTime: 60_000, + }) + + // waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl } + const waiterMap = Object.fromEntries(waiters.map(w => { + const name = w.full_name || w.nickname || w.username + const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username) + const avatarUrl = w.avatar_url ?? null + return [w.id, { name, shortName, avatarUrl }] + })) + + const tableCards = tables.map(table => { + const order = orders.find(o => + o.table_id === table.id && ['open', 'partially_paid'].includes(o.status) + ) + const tableStatus = order ? order.status : 'free' + const hasPendingPrint = order + ? order.items.some(i => i.status === 'active' && !i.printed) + : false + return { table, order, tableStatus, hasPendingPrint } + }) + + const pendingPrintOrders = tableCards.filter(c => c.hasPendingPrint) + + async function retrySingleOrder(orderId) { + setRetryingId(orderId) + try { + const res = await client.post(`/api/orders/${orderId}/retry-print`) + const results = res.data.print_results ?? [] + const allOk = results.length === 0 || results.every(r => r.success) + if (allOk) { + toast.success('Εκτυπώθηκε επιτυχώς') + } else { + const failed = results.filter(r => !r.success).map(r => r.printer_name).join(', ') + toast.error(`Αποτυχία: ${failed}`) + } + queryClient.invalidateQueries({ queryKey: ['orders-active'] }) + } catch { + toast.error('Σφάλμα επικοινωνίας') + } finally { + setRetryingId(null) + } + } + + async function retryAllOrders() { + for (const { order } of pendingPrintOrders) { + if (order) await retrySingleOrder(order.id) + } + } + + const filtered = filter === 'all' + ? tableCards + : tableCards.filter(c => c.tableStatus === filter) + + if (tablesLoading || ordersLoading) { + return
Φόρτωση…
+ } + + return ( +
+ + +
+
+ {FILTERS.map(f => ( + + ))} +
+
+ + {filtered.length === 0 && ( +

Δεν βρέθηκαν τραπέζια.

+ )} + +
+ {filtered.map(({ table, order, tableStatus, hasPendingPrint }) => { + const waiterNames = order + ? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null }) + : [] + const amount = order ? orderTotal(order.items) : null + + return ( + navigate(`/orders/${order.id}`) : undefined} + /> + ) + })} +
+ + {/* ── Draft Orders Panel ─────────────────────────────────────────────── */} + {pendingPrintOrders.length > 0 && ( +
+
+
+ +
+

Εκκρεμείς Εκτυπώσεις

+

+ {pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ +

+
+
+ +
+ +
+ {pendingPrintOrders.map(({ table, order }) => { + const unprinted = order.items.filter(i => i.status === 'active' && !i.printed) + const tableName = table.label || `T${table.number}` + return ( +
+
+ {tableName} +
+
+

+ {unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν +

+

+ {unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')} +

+
+
+ + +
+
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/manager_dashboard/src/pages/LoginPage.jsx b/manager_dashboard/src/pages/LoginPage.jsx new file mode 100644 index 0000000..f00c6d1 --- /dev/null +++ b/manager_dashboard/src/pages/LoginPage.jsx @@ -0,0 +1,344 @@ +import { useState, useEffect, useRef } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import { User, Lock, KeyRound } from 'lucide-react' +import useAuthStore from '../store/authStore' +import client from '../api/client' +import Button from '../ui/Button' +import { LabelledInput } from '../ui/Input' + +const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫'] + +function PinDots({ length, filled }) { + return ( +
+ {Array.from({ length }).map((_, i) => ( +
+ ))} +
+ ) +} + +function PinPad({ pin, onChange, disabled }) { + function press(d) { + if (disabled) return + if (d === '⌫') { onChange(p => p.slice(0, -1)); return } + if (d === '') return + if (pin.length >= 6) return + onChange(p => p + d) + } + return ( +
+ {DIGITS.map((d, i) => ( + + ))} +
+ ) +} + +// ─── Manager picker (multi-manager + login=none) ────────────────────────────── + +function ManagerPicker({ managers, onSelect }) { + return ( +
+
+

Select account

+

Choose your manager account to continue.

+
+
+ {managers.map(m => ( + + ))} +
+
+ ) +} + +// ─── Main login page ────────────────────────────────────────────────────────── + +export default function LoginPage() { + const [settings, setSettings] = useState(null) + const [managers, setManagers] = useState([]) + const [loadingInit, setLoadingInit] = useState(true) + + const [selectedManager, setSelectedManager] = useState(null) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [pin, setPin] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const { login } = useAuthStore() + const navigate = useNavigate() + const location = useLocation() + const autoLoginAttempted = useRef(false) + // manualLogout comes either from nav state (logout button) or sessionStorage (auto-logout) + const manualLogout = location.state?.manualLogout === true + || sessionStorage.getItem('pos_manual_logout') === 'true' + + // Write to sessionStorage when nav state says manualLogout, clear it on successful login + useEffect(() => { + if (location.state?.manualLogout) { + sessionStorage.setItem('pos_manual_logout', 'true') + } + }, []) + + // Load security config + manager list in parallel (both public, no auth needed) + useEffect(() => { + Promise.all([ + client.get('/api/setup/security-config').catch(() => ({ data: { login_method: 'password', autofill_username: true } })), + client.get('/api/auth/managers').catch(() => ({ data: [] })), + ]).then(([configRes, managersRes]) => { + setSettings(configRes.data) + setManagers(managersRes.data) + }).finally(() => setLoadingInit(false)) + }, []) + + const loginMethod = settings?.login_method ?? 'password' + const autofill = settings?.autofill_username !== false + const singleManager = managers.length === 1 ? managers[0] : null + + // Auto-login: none method + single manager → attempt exactly once, skip if manual logout + useEffect(() => { + if (!loadingInit && loginMethod === 'none' && singleManager && !manualLogout && !autoLoginAttempted.current) { + autoLoginAttempted.current = true + handleAutoLogin(singleManager.username) + } + }, [loadingInit, loginMethod, singleManager]) + + function clearManualLogoutFlag() { + sessionStorage.removeItem('pos_manual_logout') + } + + async function handleAutoLogin(uname) { + setLoading(true) + try { + const { data } = await client.post('/api/auth/login-no-auth', { username: uname }) + const role = data.user.role + if (role !== 'manager' && role !== 'sysadmin') { setLoading(false); return } + clearManualLogoutFlag() + login(data.user, data.access_token) + navigate('/operations', { replace: true }) + } catch { + setLoading(false) + } + } + + async function handleSubmit(e) { + e?.preventDefault() + const uname = selectedManager?.username || (autofill && singleManager ? singleManager.username : username.trim()) + if (!uname) return + if (loginMethod === 'password' && !password) return + if (loginMethod === 'pin' && pin.length < 4) return + + setError('') + setLoading(true) + try { + const body = { username: uname } + if (loginMethod === 'password') body.password = password + else if (loginMethod === 'pin') body.pin = pin + + const { data } = await client.post('/api/auth/login', body) + const role = data.user.role + if (role !== 'manager' && role !== 'sysadmin') { + setError('This account does not have manager access.') + setPin('') + setPassword('') + return + } + clearManualLogoutFlag() + login(data.user, data.access_token) + navigate('/operations', { replace: true }) + } catch (err) { + setError(err.response?.data?.detail || 'Invalid credentials') + setPin('') + setPassword('') + } finally { + setLoading(false) + } + } + + // Auto-submit on 4-digit PIN entry + useEffect(() => { + if (loginMethod === 'pin' && pin.length === 4) handleSubmit() + }, [pin]) + + // Show spinner while loading OR while auto-login is in progress (none mode, not manual logout) + if (loadingInit || (loginMethod === 'none' && singleManager && !manualLogout)) { + return ( +
+
+
+ ) + } + + // Determine effective username to show + const effectiveUsername = selectedManager?.username + || (autofill && singleManager ? singleManager.username : null) + + // Multi-manager + none method: show picker first + const needsPicker = loginMethod === 'none' && managers.length > 1 && !selectedManager + + return ( +
+
+
+ + {needsPicker ? ( + { setSelectedManager(m); handleAutoLogin(m.username) }} /> + ) : ( +
+
+
+
+ +
+
+

Sign In

+

+ {loginMethod === 'password' ? 'Enter your username and password' + : loginMethod === 'pin' ? 'Enter your username and PIN' + : 'Select your account'} +

+
+ + {/* Username — show if not autofilled */} + {!effectiveUsername && ( + <> + {managers.length > 1 ? ( +
+ {managers.map(m => ( + + ))} +
+ ) : ( + setUsername(e.target.value)} + autoComplete="off" + autoFocus + /> + )} + + )} + + {/* Show who we're logging in as when autofilled */} + {effectiveUsername && ( +
+
+ {effectiveUsername.charAt(0).toUpperCase()} +
+
+

{effectiveUsername}

+

Manager

+
+
+ )} + + {/* Password input */} + {loginMethod === 'password' && ( + setPassword(e.target.value)} + type="password" + autoComplete="current-password" + autoFocus={!!effectiveUsername} + /> + )} + + {/* PIN pad */} + {loginMethod === 'pin' && ( +
+ + +
+ )} + + {error &&

{error}

} + + {/* None mode: just a Log In button */} + {loginMethod === 'none' && ( + + )} + + {/* Password mode: submit button */} + {loginMethod === 'password' && ( + + )} + + {/* PIN mode: auto-submits, show verifying text */} + {loading && loginMethod === 'pin' && ( +

Verifying…

+ )} + + )} +
+ +

Xenia POS · Manager Dashboard

+
+
+ ) +} diff --git a/manager_dashboard/src/pages/Management/ProductFormModal.jsx b/manager_dashboard/src/pages/Management/ProductFormModal.jsx new file mode 100644 index 0000000..25981ee --- /dev/null +++ b/manager_dashboard/src/pages/Management/ProductFormModal.jsx @@ -0,0 +1,799 @@ +import { useState, useEffect } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' +import client from '../../api/client' + +// ── Small helpers ───────────────────────────────────────────────────────────── +function moveItem(arr, i, dir) { + const j = i + dir + if (j < 0 || j >= arr.length) return arr + const next = [...arr] + ;[next[i], next[j]] = [next[j], next[i]] + return next +} + +function HeartIcon({ filled, className = '' }) { + return ( + + ) +} + +function ReorderBtns({ onUp, onDown, disableUp, disableDown }) { + return ( +
+ + +
+ ) +} + +function DefaultBtn({ isDefault, onClick }) { + return ( + + ) +} + +function FavoriteBtn({ isFavorite, onClick }) { + return ( + + ) +} + +function PriceInput({ value, onChange, placeholder, className = '', allowNegative = false }) { + const step = 0.10 + const num = parseFloat(value) || 0 + function inc() { onChange(Math.round((num + step) * 100) / 100) } + function dec() { + const next = Math.round((num - step) * 100) / 100 + onChange(allowNegative ? next : Math.max(0, next)) + } + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder ?? '0.00'} + className="flex-1 min-w-0 text-center text-sm outline-none bg-transparent px-1 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> + +
+ ) +} + +function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel }) { + if (!subChoices || subChoices.length === 0) return null + return ( +
+

+ Υπο-επιλογές του «{parentLabel || '…'}» +

+ {subChoices.map((sc, sci) => ( +
+ onMove(sci, -1)} onDown={() => onMove(sci, 1)} + disableUp={sci === 0} disableDown={sci === subChoices.length - 1} /> + onToggleDefault(sci)} /> + onChange(sci, 'name', e.target.value)} /> + onChange(sci, 'extra_cost', v)} + allowNegative className="w-28 text-sm" /> + +
+ ))} + +
+ ) +} + +// ── Form builder ────────────────────────────────────────────────────────────── +function buildFormFromProduct(product) { + return { + name: product.name || '', + description: product.description || '', + category_id: product.category_id ?? '', + base_price: product.base_price ?? '', + is_available: product.is_available ?? true, + lifecycle_status: product.lifecycle_status ?? 'active', + printer_zone_id: product.printer_zone_id ?? '', + quick_options: product.quick_options?.map(q => ({ + name: q.name, price: q.price ?? 0, allow_multiple: q.allow_multiple ?? false, + sort_order: q.sort_order ?? 0, is_favorite: q.is_favorite ?? false, + favorite_sort_order: q.favorite_sort_order ?? 0, is_compact: q.is_compact ?? false, + })) ?? [], + options: product.options?.map(o => ({ + name: o.name, extra_cost: o.extra_cost ?? 0, allow_multiple: o.allow_multiple ?? false, + sub_choices: o.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [], + is_favorite: o.is_favorite ?? false, favorite_sort_order: o.favorite_sort_order ?? 0, + })) ?? [], + ingredients: product.ingredients?.map(i => ({ + name: i.name, extra_cost: i.extra_cost ?? 0, + is_favorite: i.is_favorite ?? false, favorite_sort_order: i.favorite_sort_order ?? 0, + })) ?? [], + preference_sets: product.preference_sets?.map(ps => ({ + name: ps.name, + default_choice_index: ps.choices ? ps.choices.findIndex(c => c.id === ps.default_choice_id) : -1, + choices: ps.choices?.map(c => ({ + name: c.name, extra_cost: c.extra_cost ?? 0, disables_subset: c.disables_subset ?? false, + sub_choices: c.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [], + })) ?? [], + shared_subset: ps.shared_subset ? { + name: ps.shared_subset.name, + choices: ps.shared_subset.choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [], + } : null, + is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0, + })) ?? [], + } +} + +function buildFavoritesList(form) { + const items = [] + form.quick_options.forEach((q, i) => { if (q.is_favorite) items.push({ type: 'quick', idx: i, favorite_sort_order: q.favorite_sort_order ?? 0 }) }) + form.ingredients.forEach((ing, i) => { if (ing.is_favorite) items.push({ type: 'ingredient', idx: i, favorite_sort_order: ing.favorite_sort_order ?? 0 }) }) + form.options.forEach((o, i) => { if (o.is_favorite) items.push({ type: 'option', idx: i, favorite_sort_order: o.favorite_sort_order ?? 0 }) }) + form.preference_sets.forEach((ps, i) => { if (ps.is_favorite) items.push({ type: 'pref', idx: i, favorite_sort_order: ps.favorite_sort_order ?? 0 }) }) + return items.sort((a, b) => a.favorite_sort_order - b.favorite_sort_order) +} + +function setFavSortField(form, type, idx, value) { + if (type === 'quick') return { ...form, quick_options: form.quick_options.map((q, i) => i === idx ? { ...q, favorite_sort_order: value } : q) } + if (type === 'ingredient') return { ...form, ingredients: form.ingredients.map((ing, i) => i === idx ? { ...ing, favorite_sort_order: value } : ing) } + if (type === 'option') return { ...form, options: form.options.map((o, i) => i === idx ? { ...o, favorite_sort_order: value } : o) } + if (type === 'pref') return { ...form, preference_sets: form.preference_sets.map((ps, i) => i === idx ? { ...ps, favorite_sort_order: value } : ps) } + return form +} + +function getItemLabel(form, type, idx) { + if (type === 'quick') return form.quick_options[idx]?.name || '(χωρίς όνομα)' + if (type === 'ingredient') return form.ingredients[idx]?.name || '(χωρίς όνομα)' + if (type === 'option') return form.options[idx]?.name || '(χωρίς όνομα)' + if (type === 'pref') return form.preference_sets[idx]?.name || '(χωρίς όνομα)' + return '' +} + +function getItemTypeLabel(type) { + if (type === 'quick') return 'Γρήγορη' + if (type === 'ingredient') return 'Υλικό' + if (type === 'option') return 'Έξτρα' + if (type === 'pref') return 'Προτίμηση' + return '' +} + +// ── Main modal ──────────────────────────────────────────────────────────────── +export default function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) { + const [form, setForm] = useState(() => buildFormFromProduct(product)) + const [activeTab, setActiveTab] = useState('favorites') + const [imageFile, setImageFile] = useState(null) + const [uploading, setUploading] = useState(false) + const qc = useQueryClient() + + useEffect(() => { + function onKey(e) { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + useEffect(() => { + setForm(buildFormFromProduct(product)) + setActiveTab('favorites') + setImageFile(null) + }, [product.id, product.name]) + + function setField(k, v) { setForm(f => ({ ...f, [k]: v })) } + + // Favorites reorder + function moveFavorite(favList, favIdx, dir) { + const newList = [...favList] + const swapIdx = favIdx + dir + if (swapIdx < 0 || swapIdx >= newList.length) return + const aOrder = newList[favIdx].favorite_sort_order + const bOrder = newList[swapIdx].favorite_sort_order + setForm(f => { + let next = setFavSortField(f, newList[favIdx].type, newList[favIdx].idx, bOrder) + next = setFavSortField(next, newList[swapIdx].type, newList[swapIdx].idx, aOrder) + return next + }) + } + + function toggleFavorite(type, idx) { + setForm(f => { + const currentFavs = buildFavoritesList(f) + const isFav = (() => { + if (type === 'quick') return f.quick_options[idx]?.is_favorite + if (type === 'ingredient') return f.ingredients[idx]?.is_favorite + if (type === 'option') return f.options[idx]?.is_favorite + if (type === 'pref') return f.preference_sets[idx]?.is_favorite + })() + const newSortOrder = isFav ? 0 : (currentFavs.length > 0 ? Math.max(...currentFavs.map(x => x.favorite_sort_order)) + 1 : 0) + if (type === 'quick') return { ...f, quick_options: f.quick_options.map((q, i) => i === idx ? { ...q, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : q) } + if (type === 'ingredient') return { ...f, ingredients: f.ingredients.map((ing, i) => i === idx ? { ...ing, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : ing) } + if (type === 'option') return { ...f, options: f.options.map((o, i) => i === idx ? { ...o, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : o) } + if (type === 'pref') return { ...f, preference_sets: f.preference_sets.map((ps, i) => i === idx ? { ...ps, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : ps) } + return f + }) + } + + // Quick Options + function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0, is_compact: false }] })) } + function removeQuickOption(i) { setForm(f => ({ ...f, quick_options: f.quick_options.filter((_, idx) => idx !== i) })) } + function setQuickOption(i, k, v) { setForm(f => ({ ...f, quick_options: f.quick_options.map((q, idx) => idx === i ? { ...q, [k]: v } : q) })) } + function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) } + + // Options + function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, allow_multiple: false, sub_choices: [], is_favorite: false, favorite_sort_order: 0 }] })) } + function removeOption(i) { setForm(f => ({ ...f, options: f.options.filter((_, idx) => idx !== i) })) } + function setOption(i, k, v) { setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) })) } + function moveOption(i, dir) { setForm(f => ({ ...f, options: moveItem(f.options, i, dir) })) } + + function addOptionSubChoice(oi) { + setForm(f => ({ ...f, options: f.options.map((o, idx) => + idx !== oi ? o : { ...o, sub_choices: [...(o.sub_choices || []), { name: '', extra_cost: 0, is_default: false }] } + )})) + } + function removeOptionSubChoice(oi, sci) { + setForm(f => ({ ...f, options: f.options.map((o, idx) => + idx !== oi ? o : { ...o, sub_choices: o.sub_choices.filter((_, i) => i !== sci) } + )})) + } + function setOptionSubChoice(oi, sci, k, v) { + setForm(f => ({ ...f, options: f.options.map((o, idx) => + idx !== oi ? o : { + ...o, sub_choices: o.sub_choices.map((sc, scidx) => { + if (scidx !== sci) return k === 'is_default' && v === true ? { ...sc, is_default: false } : sc + return { ...sc, [k]: v } + }) + } + )})) + } + function moveOptionSubChoice(oi, sci, dir) { + setForm(f => ({ ...f, options: f.options.map((o, idx) => + idx !== oi ? o : { ...o, sub_choices: moveItem(o.sub_choices, sci, dir) } + )})) + } + function toggleOptionSubDefault(oi, sci) { + const sc = form.options[oi]?.sub_choices?.[sci] + setOptionSubChoice(oi, sci, 'is_default', !sc?.is_default) + } + + // Ingredients + function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0, is_favorite: false, favorite_sort_order: 0 }] })) } + function removeIngredient(i) { setForm(f => ({ ...f, ingredients: f.ingredients.filter((_, idx) => idx !== i) })) } + function setIngredient(i, k, v) { setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { ...ing, [k]: v } : ing) })) } + function moveIngredient(i, dir) { setForm(f => ({ ...f, ingredients: moveItem(f.ingredients, i, dir) })) } + + // Preference sets + function addPrefSet() { + setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null, is_favorite: false, favorite_sort_order: 0 }] })) + setActiveTab(form.preference_sets.length) + } + function removePrefSet(si) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.filter((_, idx) => idx !== si) })) + setActiveTab('favorites') + } + function setPrefSetField(si, k, v) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => idx === si ? { ...ps, [k]: v } : ps) })) + } + function addChoice(si) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => + idx === si ? { ...ps, choices: [...ps.choices, { name: '', extra_cost: 0, disables_subset: false, sub_choices: [] }] } : ps + )})) + } + function removeChoice(si, ci) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + const newChoices = ps.choices.filter((_, cidx) => cidx !== ci) + const d = ps.default_choice_index + const newDefault = d === ci ? -1 : d > ci ? d - 1 : d + return { ...ps, choices: newChoices, default_choice_index: newDefault } + })})) + } + function setChoice(si, ci, k, v) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => + idx === si ? { ...ps, choices: ps.choices.map((ch, cidx) => cidx === ci ? { ...ch, [k]: v } : ch) } : ps + )})) + } + function moveChoice(si, ci, dir) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + const newChoices = moveItem(ps.choices, ci, dir) + const j = ci + dir + let nd = ps.default_choice_index + if (nd === ci) nd = j; else if (nd === j) nd = ci + return { ...ps, choices: newChoices, default_choice_index: nd } + })})) + } + function toggleDefaultChoice(si, ci) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => + idx !== si ? ps : { ...ps, default_choice_index: ps.default_choice_index === ci ? -1 : ci } + )})) + } + function addSubChoice(si, ci) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) => + pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) => + cidx !== ci ? ch : { ...ch, sub_choices: [...(ch.sub_choices || []), { name: '', extra_cost: 0, is_default: false }] } + )} + )})) + } + function removeSubChoice(si, ci, sci) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) => + pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) => + cidx !== ci ? ch : { ...ch, sub_choices: ch.sub_choices.filter((_, i) => i !== sci) } + )} + )})) + } + function setSubChoice(si, ci, sci, k, v) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) => + pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) => + cidx !== ci ? ch : { ...ch, sub_choices: ch.sub_choices.map((sc, scidx) => { + if (scidx !== sci) return k === 'is_default' && v === true ? { ...sc, is_default: false } : sc + return { ...sc, [k]: v } + })} + )} + )})) + } + function moveSubChoice(si, ci, sci, dir) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) => + pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) => + cidx !== ci ? ch : { ...ch, sub_choices: moveItem(ch.sub_choices, sci, dir) } + )} + )})) + } + function setSharedSubsetName(si, name) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + return { ...ps, shared_subset: ps.shared_subset ? { ...ps.shared_subset, name } : { name, choices: [] } } + })})) + } + function addSharedSubsetChoice(si) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + const ss = ps.shared_subset || { name: '', choices: [] } + return { ...ps, shared_subset: { ...ss, choices: [...ss.choices, { name: '', extra_cost: 0, is_default: false }] } } + })})) + } + function removeSharedSubsetChoice(si, sci) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + const newChoices = ps.shared_subset.choices.filter((_, i) => i !== sci) + return { ...ps, shared_subset: newChoices.length ? { ...ps.shared_subset, choices: newChoices } : null } + })})) + } + function setSharedSubsetChoice(si, sci, k, v) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + const newChoices = ps.shared_subset.choices.map((sc, scidx) => { + if (scidx !== sci) return k === 'is_default' && v === true ? { ...sc, is_default: false } : sc + return { ...sc, [k]: v } + }) + return { ...ps, shared_subset: { ...ps.shared_subset, choices: newChoices } } + })})) + } + function moveSharedSubsetChoice(si, sci, dir) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => + idx !== si ? ps : { ...ps, shared_subset: { ...ps.shared_subset, choices: moveItem(ps.shared_subset.choices, sci, dir) } } + )})) + } + + function buildBody() { + return { + name: form.name, + description: form.description || null, + category_id: form.category_id ? Number(form.category_id) : null, + base_price: parseFloat(form.base_price), + is_available: form.is_available, + lifecycle_status: form.lifecycle_status, + printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null, + quick_options: form.quick_options.map((q, i) => ({ + name: q.name, price: parseFloat(q.price) || 0, allow_multiple: q.allow_multiple ?? false, + sort_order: i, is_favorite: q.is_favorite ?? false, favorite_sort_order: q.favorite_sort_order ?? 0, is_compact: q.is_compact ?? false, + })), + options: form.options.map(o => ({ + name: o.name, extra_cost: parseFloat(o.extra_cost) || 0, allow_multiple: o.allow_multiple ?? false, + sub_choices: (o.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })), + is_favorite: o.is_favorite ?? false, favorite_sort_order: o.favorite_sort_order ?? 0, + })), + ingredients: form.ingredients.map(i => ({ + name: i.name, extra_cost: parseFloat(i.extra_cost) || 0, + is_favorite: i.is_favorite ?? false, favorite_sort_order: i.favorite_sort_order ?? 0, + })), + preference_sets: form.preference_sets.map(ps => ({ + name: ps.name, + default_choice_index: ps.default_choice_index >= 0 ? ps.default_choice_index : null, + shared_subset: ps.shared_subset?.choices?.length ? { + name: ps.shared_subset.name || '', + choices: ps.shared_subset.choices.map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })), + } : null, + choices: ps.choices.map(c => ({ + name: c.name, extra_cost: parseFloat(c.extra_cost) || 0, disables_subset: c.disables_subset ?? false, + sub_choices: (c.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })), + })), + is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0, + })), + } + } + + async function submit() { + if (!imageFile) { onSave(buildBody()); return } + if (!isNew) { + onSave(buildBody()) + setUploading(true) + try { + const fd = new FormData(); fd.append('file', imageFile) + await client.post(`/api/products/${product.id}/image`, fd) + qc.invalidateQueries({ queryKey: ['products-all'] }) + } catch { toast.error('Σφάλμα ανεβάσματος εικόνας') } + finally { setUploading(false) } + } else { + setUploading(true) + try { + const res = await client.post('/api/products/', buildBody()) + const newId = res.data.id + const fd = new FormData(); fd.append('file', imageFile) + await client.post(`/api/products/${newId}/image`, fd) + qc.invalidateQueries({ queryKey: ['products-all'] }) + onClose() + } catch { toast.error('Σφάλμα αποθήκευσης') } + finally { setUploading(false) } + } + } + + const isNew = !product.id + const canSave = form.name.trim() && form.base_price + const favCount = buildFavoritesList(form).length + const tabs = [ + { key: 'favorites', label: 'Αγαπημένα', count: favCount, isFavTab: true }, + { key: 'quick', label: 'Γρήγορες', count: form.quick_options.length }, + { key: 'ingredients', label: 'Υλικά', count: form.ingredients.length }, + { key: 'options', label: 'Έξτρα', count: form.options.length }, + ...form.preference_sets.map((ps, i) => ({ key: i, label: ps.name || `Προτ. ${i + 1}`, count: ps.choices.length })), + { key: '__add_pref__', label: '+ Προτίμηση', isAdd: true }, + ] + const favList = buildFavoritesList(form) + + return ( +
+
+ + {/* Header */} +
+

+ {isNew ? 'Νέο προϊόν' : `Επεξεργασία — ${product.name}`} +

+ +
+ + {/* Body */} +
+ + {/* LEFT: product info */} +
+

Στοιχεία προϊόντος

+ +
+ + setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" /> +
+ + {/* Description — optional, for digital menus / staff info */} +
+ +