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 <noreply@anthropic.com>
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal file
@@ -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
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -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
|
||||
0
certs/.gitkeep
Normal file
0
certs/.gitkeep
Normal file
20
docker-compose.dev.yml
Normal file
20
docker-compose.dev.yml
Normal file
@@ -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"
|
||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -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
|
||||
111
install.sh
Normal file
111
install.sh
Normal file
@@ -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
|
||||
7
local_backend/.dockerignore
Normal file
7
local_backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
pos.db
|
||||
license_state.json
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
data/
|
||||
10
local_backend/Dockerfile
Normal file
10
local_backend/Dockerfile
Normal file
@@ -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"]
|
||||
27
local_backend/config.py
Normal file
27
local_backend/config.py
Normal file
@@ -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()
|
||||
20
local_backend/database.py
Normal file
20
local_backend/database.py
Normal file
@@ -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()
|
||||
277
local_backend/main.py
Normal file
277
local_backend/main.py
Normal file
@@ -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"])
|
||||
0
local_backend/middleware/__init__.py
Normal file
0
local_backend/middleware/__init__.py
Normal file
69
local_backend/middleware/license_check.py
Normal file
69
local_backend/middleware/license_check.py
Normal file
@@ -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)
|
||||
0
local_backend/models/__init__.py
Normal file
0
local_backend/models/__init__.py
Normal file
24
local_backend/models/business_day.py
Normal file
24
local_backend/models/business_day.py
Normal file
@@ -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")
|
||||
38
local_backend/models/flag.py
Normal file
38
local_backend/models/flag.py
Normal file
@@ -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")
|
||||
48
local_backend/models/message.py
Normal file
48
local_backend/models/message.py
Normal file
@@ -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")
|
||||
131
local_backend/models/order.py
Normal file
131
local_backend/models/order.py
Normal file
@@ -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")
|
||||
17
local_backend/models/printer.py
Normal file
17
local_backend/models/printer.py
Normal file
@@ -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")
|
||||
123
local_backend/models/product.py
Normal file
123
local_backend/models/product.py
Normal file
@@ -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")
|
||||
19
local_backend/models/settings.py
Normal file
19
local_backend/models/settings.py
Normal file
@@ -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])
|
||||
36
local_backend/models/shift.py
Normal file
36
local_backend/models/shift.py
Normal file
@@ -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")
|
||||
31
local_backend/models/table.py
Normal file
31
local_backend/models/table.py
Normal file
@@ -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")
|
||||
70
local_backend/models/user.py
Normal file
70
local_backend/models/user.py
Normal file
@@ -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")
|
||||
137
local_backend/print_size_test.py
Normal file
137
local_backend/print_size_test.py
Normal file
@@ -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.")
|
||||
343
local_backend/print_test.py
Normal file
343
local_backend/print_test.py
Normal file
@@ -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()
|
||||
10
local_backend/requirements.txt
Normal file
10
local_backend/requirements.txt
Normal file
@@ -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
|
||||
0
local_backend/routers/__init__.py
Normal file
0
local_backend/routers/__init__.py
Normal file
174
local_backend/routers/auth.py
Normal file
174
local_backend/routers/auth.py
Normal file
@@ -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
|
||||
195
local_backend/routers/business_day.py
Normal file
195
local_backend/routers/business_day.py
Normal file
@@ -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}
|
||||
342
local_backend/routers/data_transfer.py
Normal file
342
local_backend/routers/data_transfer.py
Normal file
@@ -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."}
|
||||
64
local_backend/routers/deps.py
Normal file
64
local_backend/routers/deps.py
Normal file
@@ -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
|
||||
165
local_backend/routers/flags.py
Normal file
165
local_backend/routers/flags.py
Normal file
@@ -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": []})
|
||||
215
local_backend/routers/messages.py
Normal file
215
local_backend/routers/messages.py
Normal file
@@ -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]
|
||||
803
local_backend/routers/orders.py
Normal file
803
local_backend/routers/orders.py
Normal file
@@ -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"}
|
||||
331
local_backend/routers/products.py
Normal file
331
local_backend/routers/products.py
Normal file
@@ -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()
|
||||
1160
local_backend/routers/reports.py
Normal file
1160
local_backend/routers/reports.py
Normal file
File diff suppressed because it is too large
Load Diff
105
local_backend/routers/settings.py
Normal file
105
local_backend/routers/settings.py
Normal file
@@ -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}
|
||||
116
local_backend/routers/setup.py
Normal file
116
local_backend/routers/setup.py
Normal file
@@ -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)
|
||||
359
local_backend/routers/shifts.py
Normal file
359
local_backend/routers/shifts.py
Normal file
@@ -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
|
||||
60
local_backend/routers/sse.py
Normal file
60
local_backend/routers/sse.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
SSE stream endpoint — one long-lived GET per connected phone.
|
||||
|
||||
Authentication: token passed as query param ?token=<jwt>
|
||||
(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",
|
||||
},
|
||||
)
|
||||
179
local_backend/routers/system.py
Normal file
179
local_backend/routers/system.py
Normal file
@@ -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"}
|
||||
227
local_backend/routers/tables.py
Normal file
227
local_backend/routers/tables.py
Normal file
@@ -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
|
||||
186
local_backend/routers/waiters.py
Normal file
186
local_backend/routers/waiters.py
Normal file
@@ -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()
|
||||
0
local_backend/schemas/__init__.py
Normal file
0
local_backend/schemas/__init__.py
Normal file
21
local_backend/schemas/auth.py
Normal file
21
local_backend/schemas/auth.py
Normal file
@@ -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
|
||||
14
local_backend/schemas/base.py
Normal file
14
local_backend/schemas/base.py
Normal file
@@ -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",
|
||||
),
|
||||
]
|
||||
24
local_backend/schemas/business_day.py
Normal file
24
local_backend/schemas/business_day.py
Normal file
@@ -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
|
||||
47
local_backend/schemas/flag.py
Normal file
47
local_backend/schemas/flag.py
Normal file
@@ -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]
|
||||
42
local_backend/schemas/message.py
Normal file
42
local_backend/schemas/message.py
Normal file
@@ -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}
|
||||
126
local_backend/schemas/order.py
Normal file
126
local_backend/schemas/order.py
Normal file
@@ -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}
|
||||
31
local_backend/schemas/printer.py
Normal file
31
local_backend/schemas/printer.py
Normal file
@@ -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}
|
||||
302
local_backend/schemas/product.py
Normal file
302
local_backend/schemas/product.py
Normal file
@@ -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}
|
||||
16
local_backend/schemas/settings.py
Normal file
16
local_backend/schemas/settings.py
Normal file
@@ -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
|
||||
38
local_backend/schemas/shift.py
Normal file
38
local_backend/schemas/shift.py
Normal file
@@ -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
|
||||
87
local_backend/schemas/table.py
Normal file
87
local_backend/schemas/table.py
Normal file
@@ -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}
|
||||
61
local_backend/schemas/user.py
Normal file
61
local_backend/schemas/user.py
Normal file
@@ -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}
|
||||
45
local_backend/seed.py
Normal file
45
local_backend/seed.py
Normal file
@@ -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()
|
||||
0
local_backend/services/__init__.py
Normal file
0
local_backend/services/__init__.py
Normal file
179
local_backend/services/cloud_sync.py
Normal file
179
local_backend/services/cloud_sync.py
Normal file
@@ -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
|
||||
899
local_backend/services/printer_service.py
Normal file
899
local_backend/services/printer_service.py
Normal file
@@ -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
|
||||
84
local_backend/services/sse_bus.py
Normal file
84
local_backend/services/sse_bus.py
Normal file
@@ -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": "<event_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
|
||||
3
manager_dashboard/.dockerignore
Normal file
3
manager_dashboard/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
18
manager_dashboard/Dockerfile
Normal file
18
manager_dashboard/Dockerfile
Normal file
@@ -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;"]
|
||||
15
manager_dashboard/index.html
Normal file
15
manager_dashboard/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>POS Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
manager_dashboard/nginx.conf
Normal file
22
manager_dashboard/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
3564
manager_dashboard/package-lock.json
generated
Normal file
3564
manager_dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
manager_dashboard/package.json
Normal file
32
manager_dashboard/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
3
manager_dashboard/postcss.config.js
Normal file
3
manager_dashboard/postcss.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
}
|
||||
90
manager_dashboard/src/App.jsx
Normal file
90
manager_dashboard/src/App.jsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 <Spinner />
|
||||
return children
|
||||
}
|
||||
|
||||
function RequireAuth({ children }) {
|
||||
const token = useAuthStore(s => s.token)
|
||||
return token ? children : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
// 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 <Spinner />
|
||||
return children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthRehydrator>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupWizard />} />
|
||||
<Route path="/login" element={<SetupGuard><LoginPage /></SetupGuard>} />
|
||||
<Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||
<Route index element={<Navigate to="/operations" replace />} />
|
||||
<Route path="dashboard" element={<Navigate to="/operations" replace />} />
|
||||
<Route path="operations" element={<OperationsPage />} />
|
||||
<Route path="tables" element={<TablesPage />} />
|
||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||
<Route path="management" element={<ManagementPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthRehydrator>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
25
manager_dashboard/src/api/client.js
Normal file
25
manager_dashboard/src/api/client.js
Normal file
@@ -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
|
||||
14
manager_dashboard/src/components/ConfirmModal.jsx
Normal file
14
manager_dashboard/src/components/ConfirmModal.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function ConfirmModal({ title, message, confirmLabel = 'Επιβεβαίωση', confirmClass = 'btn-danger', onConfirm, onCancel }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">{title}</h2>
|
||||
{message && <p className="text-gray-600 text-sm">{message}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={onCancel} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button onClick={onConfirm} className={`flex-1 btn ${confirmClass}`}>{confirmLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
manager_dashboard/src/components/DateInput.jsx
Normal file
79
manager_dashboard/src/components/DateInput.jsx
Normal file
@@ -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 <input>: 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 (
|
||||
<div
|
||||
className={`relative cursor-pointer ${className}`}
|
||||
onClick={() => ref.current?.showPicker?.()}
|
||||
>
|
||||
{/* Visible display */}
|
||||
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
|
||||
{value ? formatDateGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ</span>}
|
||||
</div>
|
||||
{/* Native input — invisible but functional (provides the picker) */}
|
||||
<input
|
||||
ref={ref}
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DateTimeInput({ value, onChange, className = '', ...rest }) {
|
||||
const ref = useRef(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative cursor-pointer ${className}`}
|
||||
onClick={() => ref.current?.showPicker?.()}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
|
||||
{value ? formatDateTimeGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ ΩΩ:ΛΛ</span>}
|
||||
</div>
|
||||
<input
|
||||
ref={ref}
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
manager_dashboard/src/components/EditProfileModal.jsx
Normal file
166
manager_dashboard/src/components/EditProfileModal.jsx
Normal file
@@ -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 (
|
||||
<Modal title="Edit Profile" onClose={onClose} maxWidth="max-w-lg">
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="space-y-2">
|
||||
<LabelledInput
|
||||
label="Display Name"
|
||||
value={fullName}
|
||||
onChange={e => setFullName(e.target.value)}
|
||||
placeholder="e.g. Maria Papadopoulou"
|
||||
/>
|
||||
<LabelledInput
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Change Password <span className="normal-case font-normal">(leave blank to keep current)</span>
|
||||
</p>
|
||||
<LabelledInput
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<LabelledInput
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<LabelledInput
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Change PIN <span className="normal-case font-normal">(leave blank to keep current)</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<LabelledInput
|
||||
label="New PIN"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
value={newPin}
|
||||
onChange={e => setNewPin(onlyDigits(e.target.value, 4))}
|
||||
placeholder="••••"
|
||||
className="flex-1"
|
||||
/>
|
||||
<LabelledInput
|
||||
label="Confirm PIN"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
value={confirmPin}
|
||||
onChange={e => setConfirmPin(onlyDigits(e.target.value, 4))}
|
||||
placeholder="••••"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-[12px] text-rose-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
47
manager_dashboard/src/components/Sidebar.jsx
Normal file
47
manager_dashboard/src/components/Sidebar.jsx
Normal file
@@ -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 (
|
||||
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
|
||||
{/* Logo / collapse toggle */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-primary-700">
|
||||
{!collapsed && <span className="font-bold text-lg tracking-wide">POS</span>}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
className="p-1 rounded hover:bg-primary-700 transition-colors ml-auto"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 space-y-1 px-2">
|
||||
{NAV.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-3 rounded-lg font-medium transition-colors min-h-[44px] ` +
|
||||
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700')
|
||||
}
|
||||
>
|
||||
<Icon size={20} className="shrink-0" />
|
||||
{!collapsed && <span className="text-sm">{label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
18
manager_dashboard/src/components/StatusBadge.jsx
Normal file
18
manager_dashboard/src/components/StatusBadge.jsx
Normal file
@@ -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 (
|
||||
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
56
manager_dashboard/src/components/UserMenuButton.jsx
Normal file
56
manager_dashboard/src/components/UserMenuButton.jsx
Normal file
@@ -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 (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="flex items-center gap-1 text-[13px] text-slate-600 font-medium hover:text-slate-900 transition-colors"
|
||||
>
|
||||
{displayName}
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-xl border border-slate-200 bg-white shadow-lg py-1 z-50">
|
||||
<button
|
||||
onClick={() => { setOpen(false); onEditProfile() }}
|
||||
className="flex w-full items-center gap-2.5 px-3.5 py-2 text-[13px] text-slate-700 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<User className="h-3.5 w-3.5 text-slate-400" />
|
||||
Edit Profile
|
||||
</button>
|
||||
<div className="my-1 border-t border-slate-100" />
|
||||
<button
|
||||
onClick={() => { setOpen(false); onSignOut() }}
|
||||
className="flex w-full items-center gap-2.5 px-3.5 py-2 text-[13px] text-rose-500 hover:bg-rose-50 transition-colors"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
manager_dashboard/src/hooks/useLicenseStatus.js
Normal file
84
manager_dashboard/src/hooks/useLicenseStatus.js
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
5
manager_dashboard/src/icons/add.svg
Normal file
5
manager_dashboard/src/icons/add.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 12L12 12M12 12L9 12M12 12L12 9M12 12L12 15" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C21.5093 4.43821 21.8356 5.80655 21.9449 8" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 687 B |
8
manager_dashboard/src/icons/delete.svg
Normal file
8
manager_dashboard/src/icons/delete.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 11V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 11V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 7H20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 7H12H18V18C18 19.6569 16.6569 21 15 21H9C7.34315 21 6 19.6569 6 18V7Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 859 B |
4
manager_dashboard/src/icons/edit.svg
Normal file
4
manager_dashboard/src/icons/edit.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 10L14 6M18 10L21 7L17 3L14 6M18 10L17 11M14 6L8 12V16H12L14.5 13.5M20 14V20H12M10 4L4 4L4 20H7" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
5
manager_dashboard/src/icons/move-down.svg
Normal file
5
manager_dashboard/src/icons/move-down.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="-0.5 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 22.4199C17.5228 22.4199 22 17.9428 22 12.4199C22 6.89707 17.5228 2.41992 12 2.41992C6.47715 2.41992 2 6.89707 2 12.4199C2 17.9428 6.47715 22.4199 12 22.4199Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 10.99L13.13 14.05C12.9858 14.2058 12.811 14.3298 12.6166 14.4148C12.4221 14.4998 12.2122 14.5437 12 14.5437C11.7878 14.5437 11.5779 14.4998 11.3834 14.4148C11.189 14.3298 11.0142 14.2058 10.87 14.05L8 10.99" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
5
manager_dashboard/src/icons/move-up.svg
Normal file
5
manager_dashboard/src/icons/move-up.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="-0.5 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 22.4199C17.5228 22.4199 22 17.9428 22 12.4199C22 6.89707 17.5228 2.41992 12 2.41992C6.47715 2.41992 2 6.89707 2 12.4199C2 17.9428 6.47715 22.4199 12 22.4199Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.8599L10.87 10.8C11.0125 10.6416 11.1868 10.5149 11.3815 10.4282C11.5761 10.3415 11.7869 10.2966 12 10.2966C12.2131 10.2966 12.4239 10.3415 12.6185 10.4282C12.8132 10.5149 12.9875 10.6416 13.13 10.8L16 13.8599" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 797 B |
36
manager_dashboard/src/index.css
Normal file
36
manager_dashboard/src/index.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
302
manager_dashboard/src/layouts/AppLayout.jsx
Normal file
302
manager_dashboard/src/layouts/AppLayout.jsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
{locked
|
||||
? 'Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.'
|
||||
: 'Εκκρεμεί κλείδωμα από διαχειριστή — θα ενεργοποιηθεί μετά το κλείσιμο της τρέχουσας ημέρας.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// License fully expired + grace over → blocked
|
||||
if (isBlocked && lock_reason === 'expired') {
|
||||
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-[13px] font-medium">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Expiry warning (≤5 days remaining)
|
||||
if (showExpiryWarning) {
|
||||
const days = days_until_expiry
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-amber-700 text-[13px] font-medium">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Η άδεια χρήσης λήγει σε {days} {days === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε έγκαιρα.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-xs text-center space-y-5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
|
||||
<Lock className="h-6 w-6 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[15px] font-bold text-slate-900">Locked</p>
|
||||
<p className="text-[13px] text-slate-500">{displayName}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-center gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className={`w-3.5 h-3.5 rounded-full border-2 transition-colors ${
|
||||
i < pin.length ? 'bg-sky-500 border-sky-500' : 'border-slate-300'
|
||||
}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{DIGITS.map((d, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => pressDigit(d)}
|
||||
disabled={d === '' || loading}
|
||||
className={`h-12 rounded-xl text-lg font-semibold transition-colors ${
|
||||
d === '' ? 'invisible'
|
||||
: d === '⌫' ? 'bg-slate-100 hover:bg-slate-200 text-slate-600'
|
||||
: 'bg-slate-100 hover:bg-sky-100 active:bg-sky-200 text-slate-800'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[12px] text-rose-500">{error}</p>}
|
||||
{loading && <p className="text-[12px] text-slate-400">Verifying…</p>}
|
||||
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="text-[12px] text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
Sign out instead
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<LicenseContext.Provider value={license}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{locked && loginUsername && (
|
||||
<LockScreen
|
||||
username={loginUsername}
|
||||
displayName={displayName || loginUsername}
|
||||
onUnlock={handleUnlock}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<LicenseBanner license={license} />
|
||||
<header className="flex items-center justify-between px-6 py-3 bg-white border-b border-slate-200 shrink-0">
|
||||
<span className="text-[13px] font-semibold text-slate-600 tabular-nums">{timeStr}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={lock}
|
||||
title="Lock"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 hover:text-slate-700"
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<UserMenuButton
|
||||
displayName={displayName}
|
||||
onEditProfile={() => setProfileOpen(true)}
|
||||
onSignOut={handleLogout}
|
||||
/>
|
||||
{profileOpen && <EditProfileModal onClose={() => setProfileOpen(false)} />}
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</LicenseContext.Provider>
|
||||
)
|
||||
}
|
||||
21
manager_dashboard/src/main.jsx
Normal file
21
manager_dashboard/src/main.jsx
Normal file
@@ -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(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
788
manager_dashboard/src/pages/DashboardTab.jsx
Normal file
788
manager_dashboard/src/pages/DashboardTab.jsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-gray-800">Έναρξη Βάρδιας</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">✕</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Σερβιτόρος</label>
|
||||
<select className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none"
|
||||
value={waiterId} onChange={e => setWaiterId(e.target.value)}>
|
||||
<option value="">— Επιλέξτε —</option>
|
||||
{waiters.map(w => <option key={w.id} value={w.id}>{w.full_name || w.username}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Αρχικά Μετρητά (€)</label>
|
||||
<input type="number" step="0.01" min="0" placeholder="0.00" value={cash} onChange={e => 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" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Ακύρωση</button>
|
||||
<button onClick={submit} disabled={busy}
|
||||
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
|
||||
{busy ? 'Εκκίνηση…' : 'Έναρξη'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">Κλείσιμο Ημέρας</h2>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-800 space-y-2">
|
||||
<p className="font-semibold">
|
||||
{details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}
|
||||
</p>
|
||||
<p>Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Ακύρωση
|
||||
</button>
|
||||
<button onClick={onConfirm} disabled={busy}
|
||||
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
|
||||
{busy ? 'Κλείσιμο…' : 'Κλείσε Όλα & Κλείσε Ημέρα'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Some tables have unpaid items — revenue will be lost, needs hard warning
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-red-600 text-lg font-bold">!</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-800">Εκκρεμείς Πληρωμές</h2>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-800 space-y-2">
|
||||
<p className="font-semibold">
|
||||
{details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'},
|
||||
από τα οποία <span className="underline">{details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές</span>.
|
||||
</p>
|
||||
<p>Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 p-3 text-xs text-gray-500 bg-gray-50">
|
||||
Επιλέξτε <strong>Ακύρωση</strong> για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Ακύρωση
|
||||
</button>
|
||||
<button onClick={onConfirm} disabled={busy}
|
||||
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
|
||||
{busy ? 'Κλείσιμο…' : 'Αναγκαστικό Κλείσιμο'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="rounded-2xl border overflow-hidden"
|
||||
style={{ borderColor: isOpen ? '#bbf7d0' : '#e5e7eb' }}>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-5 py-3"
|
||||
style={{ background: isOpen ? '#f0fdf4' : '#f9fafb' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: isOpen ? '#16a34a' : '#9ca3af',
|
||||
boxShadow: isOpen ? '0 0 0 3px #bbf7d0' : 'none',
|
||||
}} />
|
||||
<div>
|
||||
<span className="font-bold text-sm" style={{ color: isOpen ? '#15803d' : '#6b7280' }}>
|
||||
{isOpen ? 'Εστιατόριο Ανοιχτό' : 'Εστιατόριο Κλειστό'}
|
||||
</span>
|
||||
{isOpen && businessDay?.opened_at && (
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
από {fmtTime(businessDay.opened_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isOpen && waitersWithoutShift.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowStartShift(true)}
|
||||
className="h-8 px-3 rounded-lg bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
+ Βάρδια
|
||||
</button>
|
||||
)}
|
||||
{isOpen ? (
|
||||
<button
|
||||
onClick={() => handleCloseDay(false)}
|
||||
className="h-8 px-3 rounded-lg bg-red-600 text-white text-xs font-semibold hover:bg-red-700"
|
||||
>
|
||||
Κλείσιμο Ημέρας
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleOpenDay}
|
||||
disabled={openDayMut.isPending}
|
||||
className="h-8 px-4 rounded-lg bg-green-600 text-white text-xs font-semibold hover:bg-green-700 disabled:opacity-60"
|
||||
>
|
||||
{openDayMut.isPending ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active shifts */}
|
||||
{isOpen && (
|
||||
<div className="px-5 py-3 border-t border-gray-100 bg-white">
|
||||
{activeShifts.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">Κανένας σερβιτόρος σε βάρδια</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeShifts.map(s => (
|
||||
<div key={s.id} className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-xl px-3 py-1.5">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">{s.waiter_name}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">{fmtTime(s.started_at)} · {fmtShiftDuration(s.started_at)}</span>
|
||||
{s.total_collected > 0 && (
|
||||
<span className="text-xs text-green-700 ml-2 font-medium">€{s.total_collected.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEndShift(s.id, s.waiter_name)}
|
||||
className="text-xs text-red-500 hover:text-red-700 ml-1 font-medium"
|
||||
title="Τέλος βάρδιας"
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showStartShift && (
|
||||
<StartShiftModal
|
||||
waiters={waitersWithoutShift}
|
||||
onClose={() => setShowStartShift(false)}
|
||||
onStart={handleStartShift}
|
||||
/>
|
||||
)}
|
||||
{closeDetails && (
|
||||
<CloseConfirmModal
|
||||
details={closeDetails}
|
||||
onClose={() => setCloseDetails(null)}
|
||||
onConfirm={() => handleCloseDay(true)}
|
||||
busy={forceClosing}
|
||||
/>
|
||||
)}
|
||||
{licenseBlock && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-7 w-full max-w-sm text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
|
||||
licenseBlock.code === 'SYSTEM_LOCKED' ? 'bg-red-100' : 'bg-orange-100'
|
||||
}`}>
|
||||
<span className="text-2xl">{licenseBlock.code === 'SYSTEM_LOCKED' ? '🔒' : '⚠️'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-[15px] font-bold text-slate-900">
|
||||
{licenseBlock.code === 'SYSTEM_LOCKED' ? 'Σύστημα Κλειδωμένο' : 'Άδεια Χρήσης Ληγμένη'}
|
||||
</h2>
|
||||
<p className="text-[13px] text-slate-600">{licenseBlock.message}</p>
|
||||
<button
|
||||
onClick={() => setLicenseBlock(null)}
|
||||
className="w-full h-10 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-[13px] font-semibold transition-colors"
|
||||
>
|
||||
Κλείσιμο
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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 (
|
||||
<img
|
||||
src={waiter.avatarUrl}
|
||||
alt={waiter.name}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
|
||||
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const parts = waiter.name.trim().split(' ')
|
||||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(waiter.name),
|
||||
color: 'white',
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}>{initials}</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => { setHover(false); setPressed(false) }}
|
||||
onMouseDown={() => setPressed(true)}
|
||||
onMouseUp={() => setPressed(false)}
|
||||
style={{
|
||||
'--cardBg': s.tint,
|
||||
position: 'relative',
|
||||
width: '100%', minWidth: 330, height: 200,
|
||||
padding: '16px 18px 16px 24px',
|
||||
background: s.tint,
|
||||
border: '1px solid ' + s.tintStrong,
|
||||
borderRadius: 14,
|
||||
boxShadow: pressed
|
||||
? 'inset 0 2px 4px rgba(16,20,24,0.08)'
|
||||
: hover
|
||||
? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
|
||||
: '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
|
||||
transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
|
||||
transition: 'transform 120ms ease, box-shadow 120ms ease',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
outline: 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* left accent bar */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
|
||||
background: s.accent,
|
||||
borderRadius: '14px 0 0 14px',
|
||||
}} />
|
||||
|
||||
{/* Header: name + status pill */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{
|
||||
fontSize: 34, fontWeight: 700, lineHeight: 1,
|
||||
letterSpacing: -0.5,
|
||||
color: '#111315',
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
height: 26, padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
background: s.accent,
|
||||
color: 'white',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags row */}
|
||||
<div style={{ marginTop: 8, height: 22, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{hasPendingPrint && (
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: '#92400e', color: '#fcd34d',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
⏳ Εκκρεμής εκτύπωση
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
|
||||
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 22, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: '#111315',
|
||||
}}>
|
||||
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 36,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: '#2b2f33',
|
||||
background: 'white', border: '1px solid #dfe2e6',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full p-6 space-y-6">
|
||||
<BusinessDayPanel />
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex gap-2">
|
||||
{FILTERS.map(f => (
|
||||
<Button
|
||||
key={f}
|
||||
size="sm"
|
||||
variant={filter === f ? 'primary' : 'secondary'}
|
||||
onClick={() => setFilter(f)}
|
||||
>
|
||||
{FILTER_LABELS[f]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
|
||||
{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 (
|
||||
<TableCardV1
|
||||
key={table.id}
|
||||
name={table.label || `T${table.number}`}
|
||||
status={tableStatus}
|
||||
amount={amount}
|
||||
openedAt={order?.opened_at ?? null}
|
||||
waiters={waiterNames}
|
||||
hasPendingPrint={hasPendingPrint}
|
||||
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Draft Orders Panel ─────────────────────────────────────────────── */}
|
||||
{pendingPrintOrders.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-orange-200 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-orange-100" style={{ background: '#fff7ed' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span style={{ fontSize: 20 }}>⏳</span>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-orange-900">Εκκρεμείς Εκτυπώσεις</h2>
|
||||
<p className="text-xs text-orange-700 mt-0.5">
|
||||
{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className="!bg-orange-700 !border-orange-700 hover:!bg-orange-800"
|
||||
onClick={retryAllOrders}
|
||||
disabled={retryingId !== null}
|
||||
>
|
||||
{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-orange-50">
|
||||
{pendingPrintOrders.map(({ table, order }) => {
|
||||
const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
|
||||
const tableName = table.label || `T${table.number}`
|
||||
return (
|
||||
<div key={order.id} className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center font-bold text-sm"
|
||||
style={{ background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' }}>
|
||||
{tableName}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/orders/${order.id}`)}
|
||||
>
|
||||
Λεπτομέρειες
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className="!bg-orange-700 !border-orange-700 hover:!bg-orange-800"
|
||||
onClick={() => retrySingleOrder(order.id)}
|
||||
disabled={retryingId === order.id}
|
||||
>
|
||||
{retryingId === order.id ? '…' : 'Εκτύπωση'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
344
manager_dashboard/src/pages/LoginPage.jsx
Normal file
344
manager_dashboard/src/pages/LoginPage.jsx
Normal file
@@ -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 (
|
||||
<div className="flex justify-center gap-3 py-2">
|
||||
{Array.from({ length }).map((_, i) => (
|
||||
<div key={i} className={`w-3.5 h-3.5 rounded-full border-2 transition-colors ${
|
||||
i < filled ? 'bg-sky-500 border-sky-500' : 'border-slate-300'
|
||||
}`} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{DIGITS.map((d, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => press(d)}
|
||||
disabled={d === '' || disabled}
|
||||
className={`h-14 rounded-xl text-xl font-semibold transition-colors ${
|
||||
d === ''
|
||||
? 'invisible'
|
||||
: d === '⌫'
|
||||
? 'bg-slate-100 hover:bg-slate-200 text-slate-600'
|
||||
: 'bg-slate-100 hover:bg-sky-100 active:bg-sky-200 text-slate-800'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Manager picker (multi-manager + login=none) ──────────────────────────────
|
||||
|
||||
function ManagerPicker({ managers, onSelect }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-bold text-slate-900">Select account</h1>
|
||||
<p className="text-[13px] text-slate-500">Choose your manager account to continue.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{managers.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => onSelect(m)}
|
||||
className="w-full flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 text-left transition hover:border-sky-300 hover:bg-sky-50 active:bg-sky-100"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[13px] flex-shrink-0">
|
||||
{(m.full_name || m.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-slate-800">{m.full_name || m.username}</p>
|
||||
<p className="text-[12px] text-slate-400">{m.username}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white rounded-2xl shadow-xl shadow-slate-200/60 border border-slate-100 p-8">
|
||||
|
||||
{needsPicker ? (
|
||||
<ManagerPicker managers={managers} onSelect={m => { setSelectedManager(m); handleAutoLogin(m.username) }} />
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-sky-500">
|
||||
<KeyRound className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-slate-900">Sign In</h1>
|
||||
<p className="text-[13px] text-slate-500">
|
||||
{loginMethod === 'password' ? 'Enter your username and password'
|
||||
: loginMethod === 'pin' ? 'Enter your username and PIN'
|
||||
: 'Select your account'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Username — show if not autofilled */}
|
||||
{!effectiveUsername && (
|
||||
<>
|
||||
{managers.length > 1 ? (
|
||||
<div className="space-y-2">
|
||||
{managers.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setUsername(m.username)}
|
||||
className={`w-full flex items-center gap-3 rounded-xl border p-3 text-left transition ${
|
||||
username === m.username
|
||||
? 'border-sky-400 bg-sky-50'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
|
||||
{(m.full_name || m.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-slate-800">{m.full_name || m.username}</p>
|
||||
<p className="text-[11px] text-slate-400">{m.username}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<LabelledInput
|
||||
icon={User}
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show who we're logging in as when autofilled */}
|
||||
{effectiveUsername && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
|
||||
{effectiveUsername.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-slate-700">{effectiveUsername}</p>
|
||||
<p className="text-[11px] text-slate-400">Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password input */}
|
||||
{loginMethod === 'password' && (
|
||||
<LabelledInput
|
||||
icon={Lock}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus={!!effectiveUsername}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PIN pad */}
|
||||
{loginMethod === 'pin' && (
|
||||
<div className="space-y-3">
|
||||
<PinDots length={4} filled={pin.length} />
|
||||
<PinPad pin={pin} onChange={setPin} disabled={loading} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-center text-[13px] text-rose-500">{error}</p>}
|
||||
|
||||
{/* None mode: just a Log In button */}
|
||||
{loginMethod === 'none' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={loading || (!effectiveUsername && !username.trim())}
|
||||
className="w-full justify-center py-3"
|
||||
onClick={() => handleAutoLogin(effectiveUsername || username.trim())}
|
||||
>
|
||||
{loading ? 'Signing in…' : 'Log In'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Password mode: submit button */}
|
||||
{loginMethod === 'password' && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || (!effectiveUsername && !username.trim()) || !password}
|
||||
className="w-full justify-center py-3"
|
||||
>
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* PIN mode: auto-submits, show verifying text */}
|
||||
{loading && loginMethod === 'pin' && (
|
||||
<p className="text-center text-[13px] text-slate-400">Verifying…</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[11px] text-slate-400 mt-4">Xenia POS · Manager Dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
799
manager_dashboard/src/pages/Management/ProductFormModal.jsx
Normal file
799
manager_dashboard/src/pages/Management/ProductFormModal.jsx
Normal file
@@ -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 (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" className={`inline-block shrink-0 ${className}`}
|
||||
fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.8"
|
||||
strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ReorderBtns({ onUp, onDown, disableUp, disableDown }) {
|
||||
return (
|
||||
<div className="flex flex-col shrink-0">
|
||||
<button type="button" onClick={onUp} disabled={disableUp}
|
||||
className="text-gray-400 hover:text-gray-600 disabled:opacity-20 leading-none px-1 py-0.5 text-xs">▲</button>
|
||||
<button type="button" onClick={onDown} disabled={disableDown}
|
||||
className="text-gray-400 hover:text-gray-600 disabled:opacity-20 leading-none px-1 py-0.5 text-xs">▼</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DefaultBtn({ isDefault, onClick }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 transition-all ${
|
||||
isDefault ? 'border-primary-600 bg-primary-600' : 'border-gray-300 bg-white hover:border-primary-400'
|
||||
}`}>
|
||||
{isDefault && <span className="w-2.5 h-2.5 rounded-full bg-white block" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FavoriteBtn({ isFavorite, onClick }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 transition-all ${
|
||||
isFavorite ? 'text-rose-500 hover:text-rose-400' : 'text-gray-300 hover:text-rose-400'
|
||||
}`}>
|
||||
<HeartIcon filled={isFavorite} className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`flex items-center border border-gray-300 rounded-lg overflow-hidden h-10 ${className}`}>
|
||||
<button type="button" onClick={dec}
|
||||
className="px-2 h-full text-gray-500 hover:bg-gray-100 border-r border-gray-300 text-sm font-bold shrink-0">−</button>
|
||||
<input type="number" step="0.10" value={value} onChange={e => 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" />
|
||||
<button type="button" onClick={inc}
|
||||
className="px-2 h-full text-gray-500 hover:bg-gray-100 border-l border-gray-300 text-sm font-bold shrink-0">+</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel }) {
|
||||
if (!subChoices || subChoices.length === 0) return null
|
||||
return (
|
||||
<div className="border-t border-gray-100 bg-indigo-50/40 px-3 py-2 space-y-2">
|
||||
<p className="text-xs text-indigo-500 font-medium mb-1">
|
||||
Υπο-επιλογές του «{parentLabel || '…'}»
|
||||
</p>
|
||||
{subChoices.map((sc, sci) => (
|
||||
<div key={sci} className="flex items-center gap-2 ml-4">
|
||||
<ReorderBtns onUp={() => onMove(sci, -1)} onDown={() => onMove(sci, 1)}
|
||||
disableUp={sci === 0} disableDown={sci === subChoices.length - 1} />
|
||||
<DefaultBtn isDefault={sc.is_default} onClick={() => onToggleDefault(sci)} />
|
||||
<input className="input flex-1 text-sm" placeholder="π.χ. Καραμέλα"
|
||||
value={sc.name} onChange={e => onChange(sci, 'name', e.target.value)} />
|
||||
<PriceInput value={sc.extra_cost} onChange={v => onChange(sci, 'extra_cost', v)}
|
||||
allowNegative className="w-28 text-sm" />
|
||||
<button onClick={() => onRemove(sci)} className="btn btn-danger px-2 min-h-0 h-9 text-sm shrink-0">✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={onAdd} className="ml-4 btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">
|
||||
+ Υπο-επιλογή
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden" style={{ width: '90vw', height: '92vh' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
||||
<h2 className="font-bold text-gray-800 text-lg">
|
||||
{isNew ? 'Νέο προϊόν' : `Επεξεργασία — ${product.name}`}
|
||||
</h2>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center text-gray-500 text-lg">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
|
||||
{/* LEFT: product info */}
|
||||
<div className="w-80 shrink-0 border-r border-gray-100 bg-gray-50/50 px-5 py-5 flex flex-col gap-3 overflow-y-auto">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest">Στοιχεία προϊόντος</p>
|
||||
|
||||
<div>
|
||||
<label className="label">Όνομα *</label>
|
||||
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
|
||||
</div>
|
||||
|
||||
{/* Description — optional, for digital menus / staff info */}
|
||||
<div>
|
||||
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
|
||||
<textarea
|
||||
className="input resize-none"
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={e => setField('description', e.target.value)}
|
||||
placeholder="π.χ. Φρέσκος καφές με γάλα βρώμης, εξαιρετικά αρώματα…"
|
||||
style={{ lineHeight: 1.5, fontSize: 13 }}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Χρήσιμο για ψηφιακό μενού ή ενημέρωση σερβιτόρων.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Τιμή βάσης (€) *</label>
|
||||
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Κατηγορία</label>
|
||||
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
|
||||
<option value="">— Χωρίς κατηγορία —</option>
|
||||
{categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order).flatMap(parent => {
|
||||
const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
|
||||
return [
|
||||
<option key={parent.id} value={parent.id}>{parent.name}</option>,
|
||||
...subs.map(s => <option key={s.id} value={s.id}> ↳ {s.name}</option>),
|
||||
]
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Ζώνη εκτυπωτή</label>
|
||||
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
|
||||
<option value="">— Χωρίς εκτυπωτή —</option>
|
||||
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => setField('is_available', !form.is_available)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
form.is_available ? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
|
||||
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
|
||||
}`}>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${form.is_available ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
|
||||
</button>
|
||||
|
||||
{/* Image upload */}
|
||||
<div>
|
||||
<label className="label">Εικόνα προϊόντος</label>
|
||||
{product.image_url && (
|
||||
<img src={product.image_url}
|
||||
className="w-16 h-16 rounded-xl object-cover border border-gray-200 mb-2" alt="" />
|
||||
)}
|
||||
{imageFile && <div className="text-xs text-primary-700 font-medium mb-1 break-all">{imageFile.name}</div>}
|
||||
<label className="cursor-pointer inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-sm text-gray-600 transition-colors">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
{imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
|
||||
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: tabs */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-gray-200 overflow-x-auto shrink-0 bg-white">
|
||||
{tabs.map(tab => {
|
||||
if (tab.isAdd) return (
|
||||
<button key="__add_pref__" onClick={addPrefSet}
|
||||
className="px-4 py-3 text-sm font-medium text-primary-600 hover:bg-primary-50 whitespace-nowrap border-b-2 border-transparent transition-colors">
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
const isActive = activeTab === tab.key
|
||||
return (
|
||||
<button key={String(tab.key)} onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors flex items-center gap-1.5 ${
|
||||
isActive ? 'border-primary-600 text-primary-700 bg-primary-50/50' : 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
}`}>
|
||||
{tab.isFavTab && <HeartIcon filled={favCount > 0} className={`w-3.5 h-3.5 ${favCount > 0 ? 'text-rose-500' : 'text-gray-400'}`} />}
|
||||
{tab.label}
|
||||
{tab.count > 0 && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-mono ${isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
|
||||
{/* Favorites */}
|
||||
{activeTab === 'favorites' && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-4">Αγαπημένα — εμφανίζονται πρώτα στον σερβιτόρο.</p>
|
||||
{favList.length === 0 && <p className="text-sm text-gray-400 text-center py-12">Δεν υπάρχουν αγαπημένα.</p>}
|
||||
<div className="space-y-2">
|
||||
{favList.map((fav, fi) => (
|
||||
<div key={`${fav.type}-${fav.idx}`} className="flex items-center gap-3 border border-rose-100 bg-rose-50/30 rounded-xl p-3">
|
||||
<ReorderBtns onUp={() => moveFavorite(favList, fi, -1)} onDown={() => moveFavorite(favList, fi, 1)}
|
||||
disableUp={fi === 0} disableDown={fi === favList.length - 1} />
|
||||
<HeartIcon filled className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 truncate">{getItemLabel(form, fav.type, fav.idx)}</p>
|
||||
<p className="text-xs text-gray-400">{getItemTypeLabel(fav.type)}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => toggleFavorite(fav.type, fav.idx)}
|
||||
className="text-xs text-gray-400 hover:text-red-500 px-2 py-1 rounded hover:bg-red-50">Αφαίρεση</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Options */}
|
||||
{activeTab === 'quick' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-gray-500">Γρήγορες επιλογές.</p>
|
||||
<button onClick={addQuickOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Επιλογή</button>
|
||||
</div>
|
||||
{!form.quick_options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν γρήγορες επιλογές.</p>}
|
||||
<div className="space-y-2">
|
||||
{form.quick_options.map((q, i) => (
|
||||
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white ${q.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
|
||||
<ReorderBtns onUp={() => moveQuickOption(i, -1)} onDown={() => moveQuickOption(i, 1)}
|
||||
disableUp={i === 0} disableDown={i === form.quick_options.length - 1} />
|
||||
<FavoriteBtn isFavorite={q.is_favorite} onClick={() => toggleFavorite('quick', i)} />
|
||||
<input className="input flex-1" placeholder="π.χ. Extra Bacon" value={q.name} onChange={e => setQuickOption(i, 'name', e.target.value)} />
|
||||
<PriceInput value={q.price} onChange={v => setQuickOption(i, 'price', v)} className="w-32" />
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer shrink-0 select-none">
|
||||
<input type="checkbox" checked={q.allow_multiple} onChange={e => setQuickOption(i, 'allow_multiple', e.target.checked)} className="accent-primary-700 w-4 h-4" />
|
||||
Πολλαπλά
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: q.is_compact ? '#7c3aed' : '#6b7280' }}>
|
||||
<input type="checkbox" checked={q.is_compact ?? false} onChange={e => setQuickOption(i, 'is_compact', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#7c3aed' }} />
|
||||
Compact
|
||||
</label>
|
||||
<button onClick={() => removeQuickOption(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingredients */}
|
||||
{activeTab === 'ingredients' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-gray-500">Υλικά που ο πελάτης μπορεί να αφαιρέσει.</p>
|
||||
<button onClick={addIngredient} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Υλικό</button>
|
||||
</div>
|
||||
{!form.ingredients.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν υλικά.</p>}
|
||||
<div className="space-y-2">
|
||||
{form.ingredients.map((ing, i) => (
|
||||
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white ${ing.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
|
||||
<ReorderBtns onUp={() => moveIngredient(i, -1)} onDown={() => moveIngredient(i, 1)}
|
||||
disableUp={i === 0} disableDown={i === form.ingredients.length - 1} />
|
||||
<FavoriteBtn isFavorite={ing.is_favorite} onClick={() => toggleFavorite('ingredient', i)} />
|
||||
<input className="input flex-1" placeholder="Όνομα υλικού" value={ing.name} onChange={e => setIngredient(i, 'name', e.target.value)} />
|
||||
<PriceInput value={ing.extra_cost} onChange={v => setIngredient(i, 'extra_cost', v)} allowNegative className="w-32" />
|
||||
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options/Extras */}
|
||||
{activeTab === 'options' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-gray-500">Έξτρα (checkbox). Κάθε extra μπορεί να έχει υπο-επιλογές.</p>
|
||||
<button onClick={addOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Έξτρα</button>
|
||||
</div>
|
||||
{!form.options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν extras.</p>}
|
||||
<div className="space-y-3">
|
||||
{form.options.map((opt, i) => (
|
||||
<div key={i} className={`border rounded-xl overflow-hidden ${opt.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
|
||||
<div className="flex gap-2 items-center p-3 bg-white flex-wrap">
|
||||
<ReorderBtns onUp={() => moveOption(i, -1)} onDown={() => moveOption(i, 1)}
|
||||
disableUp={i === 0} disableDown={i === form.options.length - 1} />
|
||||
<FavoriteBtn isFavorite={opt.is_favorite} onClick={() => toggleFavorite('option', i)} />
|
||||
<input className="input flex-1 min-w-40" placeholder="π.χ. Κανέλα" value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} />
|
||||
<PriceInput value={opt.extra_cost} onChange={v => setOption(i, 'extra_cost', v)} allowNegative className="w-32" />
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer shrink-0 select-none">
|
||||
<input type="checkbox" checked={opt.allow_multiple} onChange={e => setOption(i, 'allow_multiple', e.target.checked)} className="accent-primary-700 w-4 h-4" />
|
||||
Πολλαπλά
|
||||
</label>
|
||||
<button onClick={() => addOptionSubChoice(i)} className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">+ Υπο-επιλογές</button>
|
||||
<button onClick={() => removeOption(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||||
</div>
|
||||
<SubChoiceRows subChoices={opt.sub_choices} parentLabel={opt.name}
|
||||
onMove={(sci, dir) => moveOptionSubChoice(i, sci, dir)}
|
||||
onToggleDefault={sci => toggleOptionSubDefault(i, sci)}
|
||||
onChange={(sci, k, v) => setOptionSubChoice(i, sci, k, v)}
|
||||
onRemove={sci => removeOptionSubChoice(i, sci)}
|
||||
onAdd={() => addOptionSubChoice(i)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preference set tab */}
|
||||
{typeof activeTab === 'number' && form.preference_sets[activeTab] && (() => {
|
||||
const si = activeTab
|
||||
const ps = form.preference_sets[si]
|
||||
const hasSharedSubset = !!ps.shared_subset
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<input className="input flex-1 font-semibold text-base" placeholder="π.χ. Ζάχαρη" value={ps.name}
|
||||
onChange={e => setPrefSetField(si, 'name', e.target.value)} autoFocus />
|
||||
<FavoriteBtn isFavorite={ps.is_favorite} onClick={() => toggleFavorite('pref', si)} />
|
||||
<button onClick={() => removePrefSet(si)} className="btn btn-danger px-3 min-h-0 h-10 shrink-0">Διαγραφή</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-3">● = προεπιλογή · ⊘ = απενεργοποιεί κοινό υπο-σύνολο</p>
|
||||
<div className="space-y-3 mb-5">
|
||||
{ps.choices.map((ch, ci) => (
|
||||
<div key={ci} className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center gap-2 p-3 bg-white">
|
||||
<ReorderBtns onUp={() => moveChoice(si, ci, -1)} onDown={() => moveChoice(si, ci, 1)}
|
||||
disableUp={ci === 0} disableDown={ci === ps.choices.length - 1} />
|
||||
<DefaultBtn isDefault={ps.default_choice_index === ci} onClick={() => toggleDefaultChoice(si, ci)} />
|
||||
<input className="input flex-1" placeholder="π.χ. Σκέτος" value={ch.name} onChange={e => setChoice(si, ci, 'name', e.target.value)} />
|
||||
<PriceInput value={ch.extra_cost} onChange={v => setChoice(si, ci, 'extra_cost', v)} allowNegative className="w-32" />
|
||||
{hasSharedSubset && (
|
||||
<button type="button" onClick={() => setChoice(si, ci, 'disables_subset', !ch.disables_subset)}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-sm ${ch.disables_subset ? 'bg-red-100 text-red-500' : 'text-gray-300 hover:text-red-400'}`}>⊘</button>
|
||||
)}
|
||||
<button onClick={() => addSubChoice(si, ci)} className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">+ Υπο-επιλογές</button>
|
||||
<button onClick={() => removeChoice(si, ci)} className="btn btn-danger px-2 min-h-0 h-9 shrink-0">✕</button>
|
||||
</div>
|
||||
<SubChoiceRows subChoices={ch.sub_choices} parentLabel={ch.name}
|
||||
onMove={(sci, dir) => moveSubChoice(si, ci, sci, dir)}
|
||||
onToggleDefault={sci => setSubChoice(si, ci, sci, 'is_default', !ch.sub_choices[sci]?.is_default)}
|
||||
onChange={(sci, k, v) => setSubChoice(si, ci, sci, k, v)}
|
||||
onRemove={sci => removeSubChoice(si, ci, sci)}
|
||||
onAdd={() => addSubChoice(si, ci)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => addChoice(si)} className="btn btn-secondary text-sm px-4 py-1.5 min-h-0 h-9">+ Επιλογή</button>
|
||||
<div className="mt-6 border-t border-gray-100 pt-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-700">Κοινό υπο-σύνολο</p>
|
||||
<p className="text-xs text-gray-400">Εμφανίζεται για όλες εκτός αυτών με ⊘</p>
|
||||
</div>
|
||||
{!ps.shared_subset
|
||||
? <button onClick={() => setSharedSubsetName(si, '')} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Κοινό υπο-σύνολο</button>
|
||||
: <button onClick={() => setPrefSetField(si, 'shared_subset', null)} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9">Αφαίρεση</button>
|
||||
}
|
||||
</div>
|
||||
{ps.shared_subset && (
|
||||
<div className="border border-indigo-200 rounded-xl p-4 bg-indigo-50/30 space-y-3">
|
||||
<div>
|
||||
<label className="label text-xs">Όνομα</label>
|
||||
<input className="input text-sm" placeholder="π.χ. Είδος ζάχαρης" value={ps.shared_subset.name || ''} onChange={e => setSharedSubsetName(si, e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(ps.shared_subset.choices || []).map((sc, sci) => (
|
||||
<div key={sci} className="flex items-center gap-2">
|
||||
<ReorderBtns onUp={() => moveSharedSubsetChoice(si, sci, -1)} onDown={() => moveSharedSubsetChoice(si, sci, 1)}
|
||||
disableUp={sci === 0} disableDown={sci === ps.shared_subset.choices.length - 1} />
|
||||
<DefaultBtn isDefault={sc.is_default} onClick={() => setSharedSubsetChoice(si, sci, 'is_default', !sc.is_default)} />
|
||||
<input className="input flex-1 text-sm" placeholder="π.χ. Λευκή" value={sc.name} onChange={e => setSharedSubsetChoice(si, sci, 'name', e.target.value)} />
|
||||
<PriceInput value={sc.extra_cost} onChange={v => setSharedSubsetChoice(si, sci, 'extra_cost', v)} allowNegative className="w-32 text-sm" />
|
||||
<button onClick={() => removeSharedSubsetChoice(si, sci)} className="btn btn-danger px-2 min-h-0 h-9 text-sm shrink-0">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => addSharedSubsetChoice(si)} className="btn btn-secondary text-xs px-3 py-1 min-h-0 h-7">+ Επιλογή</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-100 bg-gray-50/50 shrink-0 gap-3">
|
||||
<button onClick={onClose} className="btn btn-secondary px-6">Ακύρωση</button>
|
||||
<div className="flex gap-3">
|
||||
{!isNew && (
|
||||
<button onClick={() => onCopy({ ...form })} className="btn btn-secondary px-6 text-indigo-600 border-indigo-200 hover:bg-indigo-50">
|
||||
📋 Αντιγραφή
|
||||
</button>
|
||||
)}
|
||||
<button onClick={submit} disabled={!canSave || uploading} className="btn btn-primary px-8">
|
||||
{uploading ? 'Ανέβασμα…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1556
manager_dashboard/src/pages/Management/ProductsTab.jsx
Normal file
1556
manager_dashboard/src/pages/Management/ProductsTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
27
manager_dashboard/src/pages/ManagementPage.jsx
Normal file
27
manager_dashboard/src/pages/ManagementPage.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState } from 'react'
|
||||
import { ShoppingBag, LayoutGrid, Users } from 'lucide-react'
|
||||
import ProductsTab from './Management/ProductsTab'
|
||||
import TablesConfigTab from './TablesConfigTab'
|
||||
import StaffTab from './StaffTab'
|
||||
import { TabGroup } from '../ui/Tabs'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'products', label: 'Προϊόντα', icon: ShoppingBag },
|
||||
{ id: 'tables', label: 'Τραπέζια', icon: LayoutGrid },
|
||||
{ id: 'staff', label: 'Προσωπικό', icon: Users },
|
||||
]
|
||||
|
||||
export default function ManagementPage() {
|
||||
const [activeTab, setActiveTab] = useState('products')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<TabGroup tabs={TABS} active={activeTab} onChange={setActiveTab} />
|
||||
<div className="flex-1 min-h-0">
|
||||
{activeTab === 'products' && <ProductsTab />}
|
||||
{activeTab === 'tables' && <TablesConfigTab />}
|
||||
{activeTab === 'staff' && <StaffTab />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1654
manager_dashboard/src/pages/OperationsPage.jsx
Normal file
1654
manager_dashboard/src/pages/OperationsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
397
manager_dashboard/src/pages/OrderDetailPage.jsx
Normal file
397
manager_dashboard/src/pages/OrderDetailPage.jsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import Badge from '../ui/Badge'
|
||||
import { ConfirmModal } from '../ui/Modal'
|
||||
|
||||
function PrintOrderModal({ onClose, onPrint, printers }) {
|
||||
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
|
||||
function submit() {
|
||||
if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return }
|
||||
onPrint(Number(printerId))
|
||||
onClose()
|
||||
}
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-gray-800">Εκτύπωση παραγγελίας</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Εκτυπωτής</label>
|
||||
<select className="input w-full" value={printerId} onChange={e => setPrinterId(e.target.value)}>
|
||||
<option value="">— Επιλέξτε —</option>
|
||||
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="btn btn-secondary flex-1">Ακύρωση</button>
|
||||
<button onClick={submit} className="btn btn-primary flex-1">Εκτύπωση</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function itemTotal(item) {
|
||||
return (item.unit_price * item.quantity).toFixed(2)
|
||||
}
|
||||
|
||||
function formatDate(dt) {
|
||||
if (!dt) return '—'
|
||||
return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
const EVENT_LABELS = {
|
||||
ORDER_OPENED: 'Άνοιγμα',
|
||||
ITEMS_ADDED: 'Προσθήκη',
|
||||
PAYMENT: 'Πληρωμή',
|
||||
PAYMENT_OFFLINE: 'Πληρωμή (Offline)',
|
||||
ORDER_CLOSED: 'Κλείσιμο',
|
||||
ORDER_CANCELLED: 'Ακύρωση',
|
||||
ITEM_CANCELLED: 'Ακύρωση αντ.',
|
||||
}
|
||||
|
||||
function AuditTab({ order, waiterMap }) {
|
||||
if (!order.audit_logs || order.audit_logs.length === 0) {
|
||||
return <p className="py-8 text-center text-gray-400 text-sm">Δεν υπάρχουν εγγραφές.</p>
|
||||
}
|
||||
return (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{order.audit_logs.map(log => {
|
||||
const isDuplicate = log.is_duplicate === 1 || log.is_duplicate === true
|
||||
const isPayment = log.event_type === 'PAYMENT' || log.event_type === 'PAYMENT_OFFLINE'
|
||||
const badgeClass = isDuplicate
|
||||
? 'bg-red-100 text-red-700'
|
||||
: isPayment ? 'bg-green-100 text-green-700'
|
||||
: log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600'
|
||||
: log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
// Show offline_at (real payment time) when available, else server created_at
|
||||
const displayTime = log.offline_at ? formatDate(log.offline_at) : formatDate(log.created_at)
|
||||
return (
|
||||
<div key={log.id} className={`flex items-start gap-3 px-4 py-3 ${isDuplicate ? 'bg-red-50' : ''}`}>
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${badgeClass}`}>
|
||||
{EVENT_LABELS[log.event_type] ?? log.event_type}
|
||||
</span>
|
||||
{isDuplicate && (
|
||||
<span className="block text-xs text-red-500 font-semibold mt-0.5">ΔΙΠΛΗ</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-sm text-gray-700">
|
||||
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
|
||||
{log.amount != null && (
|
||||
<span className={`ml-2 font-semibold ${isDuplicate ? 'text-red-600' : 'text-green-700'}`}>
|
||||
€{log.amount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{log.payment_method && (
|
||||
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="text-xs text-gray-400">{displayTime}</span>
|
||||
{log.offline_at && (
|
||||
<span className="block text-xs text-orange-400">offline</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) {
|
||||
const { orderId: paramOrderId } = useParams()
|
||||
const orderId = propOrderId ?? paramOrderId
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [tab, setTab] = useState('overview')
|
||||
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
|
||||
const [showPrintModal, setShowPrintModal] = useState(false)
|
||||
|
||||
const { data: order, isLoading } = useQuery({
|
||||
queryKey: ['order', orderId],
|
||||
queryFn: () => client.get(`/api/orders/${orderId}`).then(r => r.data),
|
||||
enabled: !!orderId,
|
||||
})
|
||||
|
||||
const { data: waiters = [] } = useQuery({
|
||||
queryKey: ['waiters'],
|
||||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const { data: printers = [] } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const printOrder = useMutation({
|
||||
mutationFn: (printerId) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
|
||||
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||||
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
||||
})
|
||||
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.nickname || w.full_name || w.username]))
|
||||
const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id))
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ['order', orderId] })
|
||||
qc.invalidateQueries({ queryKey: ['orders-active'] })
|
||||
}
|
||||
|
||||
const cancelItem = useMutation({
|
||||
mutationFn: (itemId) => client.delete(`/api/orders/${orderId}/items/${itemId}`),
|
||||
onSuccess: () => { toast.success('Αντικείμενο ακυρώθηκε'); invalidate() },
|
||||
onError: () => toast.error('Σφάλμα ακύρωσης αντικειμένου'),
|
||||
})
|
||||
|
||||
const cancelOrder = useMutation({
|
||||
mutationFn: () => client.delete(`/api/orders/${orderId}`),
|
||||
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); navigate('/tables') },
|
||||
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
|
||||
})
|
||||
|
||||
const closeOrder = useMutation({
|
||||
mutationFn: () => client.post(`/api/orders/${orderId}/close`),
|
||||
onSuccess: () => { toast.success('Παραγγελία έκλεισε'); navigate('/tables') },
|
||||
onError: () => toast.error('Σφάλμα κλεισίματος'),
|
||||
})
|
||||
|
||||
const assignWaiter = useMutation({
|
||||
mutationFn: (waiter_id) => client.put(`/api/orders/${orderId}/assign-waiter`, { waiter_id }),
|
||||
onSuccess: () => { toast.success('Σερβιτόρος προστέθηκε'); invalidate() },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const removeWaiter = useMutation({
|
||||
mutationFn: (wid) => client.delete(`/api/orders/${orderId}/waiters/${wid}`),
|
||||
onSuccess: () => { toast.success('Σερβιτόρος αφαιρέθηκε'); invalidate() },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const payItems = useMutation({
|
||||
mutationFn: (item_ids) => client.post(`/api/orders/${orderId}/pay`, { item_ids }),
|
||||
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
|
||||
onError: () => toast.error('Σφάλμα πληρωμής'),
|
||||
})
|
||||
|
||||
function handleConfirm() {
|
||||
if (!confirmAction) return
|
||||
const { type, payload } = confirmAction
|
||||
if (type === 'cancelItem') cancelItem.mutate(payload)
|
||||
if (type === 'cancelOrder') cancelOrder.mutate()
|
||||
if (type === 'closeOrder') closeOrder.mutate()
|
||||
setConfirmAction(null)
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
if (!order) return <div className="text-center py-16 text-gray-400">Παραγγελία δεν βρέθηκε.</div>
|
||||
|
||||
const activeItems = order.items.filter(i => i.status === 'active')
|
||||
const total = order.items
|
||||
.filter(i => i.status !== 'cancelled')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
|
||||
const isOpen = ['open', 'partially_paid'].includes(order.status)
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full p-6">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{!propOrderId && (
|
||||
<button onClick={() => navigate(-1)} className="btn btn-ghost text-sm">← Πίσω</button>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="card p-5 flex flex-wrap gap-4 items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Τραπέζι {order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
|
||||
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge status={order.status} />
|
||||
<span className="text-lg font-bold text-gray-800">€{total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === key ? 'border-primary-600 text-primary-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'overview' && <>
|
||||
{/* Waiters */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Προσωπικό</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{order.waiters.map(w => (
|
||||
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
|
||||
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
|
||||
{isOpen && !readOnly && (
|
||||
<button
|
||||
onClick={() => removeWaiter.mutate(w.waiter_id)}
|
||||
className="text-gray-400 hover:text-red-500 text-xs leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isOpen && !readOnly && (
|
||||
<select
|
||||
className="text-sm border border-gray-300 rounded-full pl-3 pr-8 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
|
||||
defaultValue=""
|
||||
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
|
||||
>
|
||||
<option value="">+ Πρόσθεσε</option>
|
||||
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
|
||||
<option key={w.id} value={w.id}>{w.nickname || w.full_name || w.username}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
|
||||
</div>
|
||||
{order.items.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
|
||||
)}
|
||||
{order.items.map(item => (
|
||||
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
||||
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
|
||||
<p className="text-xs text-gray-500">x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ</p>
|
||||
{item.paid_by && (
|
||||
<p className="text-xs text-green-600 mt-0.5">
|
||||
Πληρώθηκε: {waiterMap[item.paid_by] ?? `#${item.paid_by}`}
|
||||
{item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge status={item.status} />
|
||||
<span className="text-sm font-semibold text-gray-700 w-14 text-right">€{itemTotal(item)}</span>
|
||||
{isOpen && !readOnly && item.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => payItems.mutate([item.id])}
|
||||
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Πληρωμή
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
|
||||
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
|
||||
>
|
||||
Ακύρωση
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{isOpen && !readOnly && activeItems.length > 0 && (
|
||||
<button
|
||||
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Πληρωμή όλων
|
||||
</button>
|
||||
)}
|
||||
{isOpen && !readOnly && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'closeOrder' })}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Κλείσιμο παραγγελίας
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
Ακύρωση παραγγελίας
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowPrintModal(true)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
🖨 Εκτύπωση
|
||||
</button>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{tab === 'audit' && (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<AuditTab order={order} waiterMap={waiterMap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction && (
|
||||
<ConfirmModal
|
||||
title={
|
||||
confirmAction.type === 'cancelItem' ? 'Ακύρωση αντικειμένου;' :
|
||||
confirmAction.type === 'cancelOrder' ? 'Ακύρωση παραγγελίας;' :
|
||||
'Κλείσιμο παραγγελίας;'
|
||||
}
|
||||
message={
|
||||
confirmAction.type === 'cancelOrder'
|
||||
? 'Η παραγγελία θα ακυρωθεί οριστικά.'
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={confirmAction.type === 'closeOrder' ? 'Κλείσιμο' : 'Ακύρωση'}
|
||||
confirmVariant={confirmAction.type === 'closeOrder' ? 'primary' : 'danger'}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPrintModal && printers.length > 0 && (
|
||||
<PrintOrderModal
|
||||
printers={printers}
|
||||
onClose={() => setShowPrintModal(false)}
|
||||
onPrint={(printerId) => printOrder.mutate(printerId)}
|
||||
/>
|
||||
)}
|
||||
{showPrintModal && printers.length === 0 && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 space-y-4">
|
||||
<p className="text-gray-700">Δεν βρέθηκαν ενεργοί εκτυπωτές.</p>
|
||||
<button onClick={() => setShowPrintModal(false)} className="btn btn-secondary w-full">Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1564
manager_dashboard/src/pages/ProductsTab.jsx
Normal file
1564
manager_dashboard/src/pages/ProductsTab.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1261
manager_dashboard/src/pages/ReportsPage.jsx
Normal file
1261
manager_dashboard/src/pages/ReportsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
35
manager_dashboard/src/pages/Settings/SettingsPage.jsx
Normal file
35
manager_dashboard/src/pages/Settings/SettingsPage.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react'
|
||||
import AppInfoTab from './tabs/AppInfoTab'
|
||||
import ColoursTab from './tabs/ColoursTab'
|
||||
import DevelopmentTab from './tabs/DevelopmentTab'
|
||||
import OperationTab from './tabs/OperationTab'
|
||||
import PrintFontsTab from './tabs/PrintFontsTab'
|
||||
import SecurityTab from './tabs/SecurityTab'
|
||||
import { TabGroup } from '../../ui/Tabs'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'app-info', label: 'Γενικά' },
|
||||
{ id: 'security', label: 'Ασφάλεια' },
|
||||
{ id: 'operation', label: 'Λειτουργία' },
|
||||
{ id: 'colours', label: 'Εμφάνιση' },
|
||||
{ id: 'print-fonts', label: 'Εκτύπωση' },
|
||||
{ id: 'development', label: 'dev' },
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState('app-info')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<TabGroup tabs={TABS} active={activeTab} onChange={setActiveTab} />
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{activeTab === 'app-info' && <AppInfoTab />}
|
||||
{activeTab === 'security' && <SecurityTab />}
|
||||
{activeTab === 'operation' && <OperationTab />}
|
||||
{activeTab === 'colours' && <ColoursTab />}
|
||||
{activeTab === 'print-fonts' && <PrintFontsTab />}
|
||||
{activeTab === 'development' && <DevelopmentTab />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
458
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
458
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import client from '../../../api/client'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
|
||||
const COMMON_TIMEZONES = [
|
||||
'Europe/Athens', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Rome',
|
||||
'Europe/Madrid', 'Europe/Amsterdam', 'Europe/Brussels', 'Europe/Bucharest',
|
||||
'Europe/Helsinki', 'Europe/Istanbul', 'America/New_York', 'America/Chicago',
|
||||
'America/Denver', 'America/Los_Angeles', 'UTC',
|
||||
]
|
||||
|
||||
function TimezoneSection() {
|
||||
const qc = useQueryClient()
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['pos-settings'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
|
||||
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
|
||||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||
})
|
||||
const currentTz = settings?.['system.timezone']?.value ?? 'Europe/Athens'
|
||||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
return (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="font-semibold text-gray-700">Ζώνη Ώρας</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Η ζώνη ώρας που χρησιμοποιεί το backend για χρονοσφραγίδες. Αν οι ώρες έναρξης βάρδιας εμφανίζονται λανθασμένες, ρυθμίστε αυτό να ταιριάζει με την τοπική σας ζώνη.
|
||||
</p>
|
||||
</div>
|
||||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && (
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={currentTz}
|
||||
onChange={e => updateMut.mutate({ key: 'system.timezone', value: e.target.value })}
|
||||
disabled={updateMut.isPending}
|
||||
className="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none flex-1 max-w-xs"
|
||||
>
|
||||
{COMMON_TIMEZONES.map(tz => <option key={tz} value={tz}>{tz}</option>)}
|
||||
</select>
|
||||
{updateMut.isPending && <span className="text-xs text-gray-400">Αποθήκευση…</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Ζώνη ώρας browser: <span className="font-medium text-gray-600">{browserTz}</span>
|
||||
{browserTz !== currentTz && (
|
||||
<span className="ml-2 text-amber-600 font-medium">⚠ Διαφέρει από τη ρύθμιση backend</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
Η αλλαγή ζώνης ώρας αποθηκεύεται και εφαρμόζεται στο frontend αμέσως. Για πλήρη εφαρμογή στον backend server (χρονοσφραγίδες), απαιτείται επανεκκίνηση του container.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function StatsSection() {
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ['system-stats'],
|
||||
queryFn: () => client.get('/api/system/stats').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const rows = [
|
||||
{ label: 'Κατηγορίες', value: stats?.categories },
|
||||
{ label: 'Προϊόντα (ενεργά)', value: stats?.products },
|
||||
{ label: 'Τραπέζια (ενεργά)', value: stats?.tables },
|
||||
{ label: 'Ζώνες Τραπεζιών', value: stats?.table_groups },
|
||||
{ label: 'Managers', value: stats?.managers },
|
||||
{ label: 'Σερβιτόροι', value: stats?.waiters },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h2 className="font-semibold text-gray-700">Στατιστικά Συστήματος</h2>
|
||||
{isLoading && <p className="text-sm text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{rows.map(({ label, value }) => (
|
||||
<>
|
||||
<div key={label + '-label'} className="text-gray-500">{label}</div>
|
||||
<div key={label + '-value'} className="font-medium text-gray-800">{value ?? '—'}</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function DoubleConfirmImport({ isOpen, onClose, onConfirm, title, summary, isPending }) {
|
||||
const [step, setStep] = useState(1)
|
||||
|
||||
function handleClose() {
|
||||
setStep(1)
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleFirst() {
|
||||
setStep(2)
|
||||
}
|
||||
|
||||
async function handleFinal() {
|
||||
await onConfirm()
|
||||
setStep(1)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
{step === 1 && (
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||||
<h3 className="font-semibold text-gray-800 text-lg">Επιβεβαίωση Εισαγωγής</h3>
|
||||
<p className="text-sm text-gray-600">{summary}</p>
|
||||
<p className="text-xs text-gray-400">Τα υπάρχοντα δεδομένα θα συγχωνευτούν — δεν θα διαγραφεί τίποτα.</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={handleClose} className="btn">Άκυρο</button>
|
||||
<button onClick={handleFirst} className="btn btn-primary">Συνέχεια →</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4 border-2 border-red-400">
|
||||
<h3 className="font-bold text-red-700 text-lg">⚠️ ΤΕΛΕΥΤΑΙΑ ΠΡΟΕΙΔΟΠΟΙΗΣΗ</h3>
|
||||
<p className="text-sm text-gray-700 font-medium">{title}</p>
|
||||
<p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
|
||||
Αυτή η ενέργεια <strong>δεν αναιρείται</strong>. Η βάση δεδομένων θα τροποποιηθεί μόνιμα.
|
||||
Είστε απολύτως σίγουροι;
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={handleClose} className="btn">Άκυρο</button>
|
||||
<button
|
||||
onClick={handleFinal}
|
||||
disabled={isPending}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
{isPending ? 'Εισαγωγή…' : 'ΝΑΙ, ΕΙΣΑΓΩΓΗ'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function DataTransferSection() {
|
||||
const qc = useQueryClient()
|
||||
const [catalogModal, setCatalogModal] = useState(false)
|
||||
const [tablesModal, setTablesModal] = useState(false)
|
||||
const [catalogPayload, setCatalogPayload] = useState(null)
|
||||
const [tablesPayload, setTablesPayload] = useState(null)
|
||||
const [catalogSummary, setCatalogSummary] = useState('')
|
||||
const [tablesSummary, setTablesSummary] = useState('')
|
||||
|
||||
const catalogImportMut = useMutation({
|
||||
mutationFn: (payload) => client.post('/api/data-transfer/import/catalog', payload).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
toast.success('Κατάλογος εισήχθη επιτυχώς')
|
||||
setCatalogModal(false)
|
||||
setCatalogPayload(null)
|
||||
qc.invalidateQueries({ queryKey: ['system-stats'] })
|
||||
qc.invalidateQueries({ queryKey: ['products'] })
|
||||
qc.invalidateQueries({ queryKey: ['categories'] })
|
||||
},
|
||||
onError: (err) => toast.error(err?.response?.data?.detail ?? 'Σφάλμα εισαγωγής'),
|
||||
})
|
||||
|
||||
const tablesImportMut = useMutation({
|
||||
mutationFn: (payload) => client.post('/api/data-transfer/import/tables', payload).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
toast.success('Τραπέζια εισήχθησαν επιτυχώς')
|
||||
setTablesModal(false)
|
||||
setTablesPayload(null)
|
||||
qc.invalidateQueries({ queryKey: ['system-stats'] })
|
||||
qc.invalidateQueries({ queryKey: ['tables'] })
|
||||
},
|
||||
onError: (err) => toast.error(err?.response?.data?.detail ?? 'Σφάλμα εισαγωγής'),
|
||||
})
|
||||
|
||||
function handleExport(bundle) {
|
||||
client.get(`/api/data-transfer/export/${bundle}`, { responseType: 'blob' })
|
||||
.then(res => {
|
||||
const disposition = res.headers['content-disposition'] ?? ''
|
||||
const match = disposition.match(/filename="([^"]+)"/)
|
||||
const filename = match ? match[1] : `xenia-${bundle}.json`
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
.catch(() => toast.error('Σφάλμα εξαγωγής'))
|
||||
}
|
||||
|
||||
function handleFileSelect(bundle, e) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.target.result)
|
||||
if (parsed.bundle !== bundle) {
|
||||
toast.error(`Λάθος αρχείο. Αναμένεται αρχείο τύπου '${bundle}'.`)
|
||||
return
|
||||
}
|
||||
if (bundle === 'catalog') {
|
||||
const cats = parsed.data?.categories ?? []
|
||||
const orphans = parsed.data?.uncategorized_products ?? []
|
||||
const prodCount = cats.reduce((acc, c) => acc + (c.products?.length ?? 0), 0) + orphans.length
|
||||
setCatalogSummary(`Πρόκειται να εισαγάγετε ${cats.length} κατηγορίες και ${prodCount} προϊόντα.`)
|
||||
setCatalogPayload(parsed)
|
||||
setCatalogModal(true)
|
||||
} else {
|
||||
const groups = parsed.data?.table_groups ?? []
|
||||
const ungrouped = parsed.data?.ungrouped_tables ?? []
|
||||
const tableCount = groups.reduce((acc, g) => acc + (g.tables?.length ?? 0), 0) + ungrouped.length
|
||||
setTablesSummary(`Πρόκειται να εισαγάγετε ${groups.length} ζώνες και ${tableCount} τραπέζια.`)
|
||||
setTablesPayload(parsed)
|
||||
setTablesModal(true)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Μη έγκυρο αρχείο JSON')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="font-semibold text-gray-700">Εισαγωγή / Εξαγωγή Δεδομένων</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Εξάγετε τα δεδομένα σας σε αρχείο ή εισάγετε δεδομένα από άλλη εγκατάσταση.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Κατάλογος Προϊόντων</p>
|
||||
<p className="text-xs text-gray-500">Κατηγορίες + Προϊόντα (χωρίς εκτυπωτές)</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button onClick={() => handleExport('catalog')} className="btn text-sm">
|
||||
Εξαγωγή
|
||||
</button>
|
||||
<label className="btn text-sm cursor-pointer">
|
||||
Εισαγωγή
|
||||
<input type="file" accept=".json" className="hidden" onChange={(e) => handleFileSelect('catalog', e)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Τραπέζια & Ζώνες</p>
|
||||
<p className="text-xs text-gray-500">Ζώνες τραπεζιών + Τραπέζια</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button onClick={() => handleExport('tables')} className="btn text-sm">
|
||||
Εξαγωγή
|
||||
</button>
|
||||
<label className="btn text-sm cursor-pointer">
|
||||
Εισαγωγή
|
||||
<input type="file" accept=".json" className="hidden" onChange={(e) => handleFileSelect('tables', e)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DoubleConfirmImport
|
||||
isOpen={catalogModal}
|
||||
onClose={() => { setCatalogModal(false); setCatalogPayload(null) }}
|
||||
onConfirm={() => catalogImportMut.mutateAsync(catalogPayload)}
|
||||
title="Εισαγωγή Καταλόγου Προϊόντων"
|
||||
summary={catalogSummary}
|
||||
isPending={catalogImportMut.isPending}
|
||||
/>
|
||||
<DoubleConfirmImport
|
||||
isOpen={tablesModal}
|
||||
onClose={() => { setTablesModal(false); setTablesPayload(null) }}
|
||||
onConfirm={() => tablesImportMut.mutateAsync(tablesPayload)}
|
||||
title="Εισαγωγή Τραπεζιών & Ζωνών"
|
||||
summary={tablesSummary}
|
||||
isPending={tablesImportMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function QRModal({ url, onClose }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl p-8 flex flex-col items-center gap-5 max-w-sm w-full mx-4"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="font-bold text-gray-800 text-lg">QR Σύνδεσης</h3>
|
||||
<p className="text-xs text-gray-500 text-center break-all">{url}</p>
|
||||
<div className="p-3 bg-white border border-gray-200 rounded-xl">
|
||||
<QRCodeSVG value={url} size={220} includeMargin={false} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Σαρώστε με το κινητό για σύνδεση στο σύστημα.
|
||||
</p>
|
||||
<button onClick={onClose} className="btn w-full text-sm">Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
return `${h}ω ${m}λ ${s}δ`
|
||||
}
|
||||
|
||||
export default function AppInfoTab() {
|
||||
const user = useAuthStore(s => s.user)
|
||||
const qc = useQueryClient()
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [qrOpen, setQrOpen] = useState(false)
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['system-status'],
|
||||
queryFn: () => client.get('/api/system/status').then(r => r.data),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
async function handleRefresh() {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await client.post('/api/system/sync-license')
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['system-status'] }),
|
||||
qc.invalidateQueries({ queryKey: ['license-status'] }),
|
||||
])
|
||||
} catch {
|
||||
// sync-license failure just means cloud was unreachable — still refresh local caches
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['system-status'] }),
|
||||
qc.invalidateQueries({ queryKey: ['license-status'] }),
|
||||
])
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* System info */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-700">Σύστημα</h2>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Ανανέωση κατάστασης"
|
||||
className="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-gray-200 bg-white text-gray-500 text-xs font-medium hover:bg-gray-50 hover:text-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className={refreshing ? 'animate-spin inline-block' : 'inline-block'}>⭮</span>
|
||||
{refreshing ? 'Ανανέωση…' : 'Ανανέωση'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="text-gray-500">Uptime</div>
|
||||
<div className="font-medium text-gray-800">{formatUptime(status?.uptime_seconds ?? 0)}</div>
|
||||
<div className="text-gray-500">Έκδοση</div>
|
||||
<div className="font-medium text-gray-800 flex items-center gap-2">
|
||||
{status?.version ?? '—'}
|
||||
{status?.latest_version && status.latest_version !== status.version && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">
|
||||
Διαθέσιμη {status.latest_version}
|
||||
</span>
|
||||
)}
|
||||
{status?.latest_version && status.latest_version === status.version && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-700">
|
||||
Ενημερωμένο
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-500">Άδεια χρήσης</div>
|
||||
<div className={`font-medium ${status?.licensed ? 'text-green-700' : 'text-red-600'}`}>
|
||||
{status?.licensed ? 'Ενεργή' : 'Ανενεργή'}
|
||||
</div>
|
||||
<div className="text-gray-500">Κατάσταση</div>
|
||||
<div className={`font-medium ${
|
||||
status?.locked ? 'text-red-600'
|
||||
: status?.lock_pending ? 'text-amber-600'
|
||||
: 'text-green-700'
|
||||
}`}>
|
||||
{status?.locked ? 'Κλειδωμένο'
|
||||
: status?.lock_pending ? 'Εκκρεμεί Κλείδωμα'
|
||||
: 'Λειτουργικό'}
|
||||
</div>
|
||||
{status?.expires_at && (
|
||||
<>
|
||||
<div className="text-gray-500">Λήξη άδειας</div>
|
||||
<div className="font-medium text-gray-800">{new Date(status.expires_at).toLocaleDateString('el-GR')}</div>
|
||||
</>
|
||||
)}
|
||||
{status?.waiter_domain && (
|
||||
<>
|
||||
<div className="text-gray-500">Waiter Domain</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-800 text-xs font-mono break-all">{status.waiter_domain}</span>
|
||||
<button
|
||||
onClick={() => setQrOpen(true)}
|
||||
className="flex items-center gap-1 h-6 px-2 rounded-md border border-gray-300 bg-white text-gray-600 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0"
|
||||
>
|
||||
QR Code
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimezoneSection />
|
||||
|
||||
<StatsSection />
|
||||
|
||||
<DataTransferSection />
|
||||
|
||||
{user?.role === 'sysadmin' && (
|
||||
<div className="card p-5 space-y-3 border-amber-200 bg-amber-50">
|
||||
<h2 className="font-semibold text-amber-800">Sysadmin</h2>
|
||||
<p className="text-sm text-amber-700">Έλεγχος κλειδώματος συστήματος.</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => client.post('/api/system/unlock').then(() => { toast.success('Ξεκλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
|
||||
className="btn btn-primary text-sm">Ξεκλείδωμα</button>
|
||||
<button onClick={() => client.post('/api/system/lock').then(() => { toast.success('Κλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
|
||||
className="btn btn-danger text-sm">Κλείδωμα</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrOpen && status?.waiter_domain && (
|
||||
<QRModal url={status.waiter_domain} onClose={() => setQrOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
494
manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
Normal file
494
manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { DEFAULT_COLOURS } from '../../../store/tableColourStore'
|
||||
import client from '../../../api/client'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
// ─── Colour slot metadata ────────────────────────────────────────────────────
|
||||
|
||||
const SLOTS = [
|
||||
{ key: 'cardBg', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
|
||||
{ key: 'badgeBg', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
|
||||
{ key: 'nameText', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
|
||||
{ key: 'badgeText', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
|
||||
]
|
||||
|
||||
const STATUSES = [
|
||||
{ key: 'free', label: 'Ελεύθερο' },
|
||||
{ key: 'open', label: 'Ανοιχτό (όχι δικό μου)' },
|
||||
{ key: 'mine', label: 'Ανοιχτό (δικό μου)' },
|
||||
{ key: 'partially_paid', label: 'Μερικώς Πληρωμένο' },
|
||||
{ key: 'paid', label: 'Πληρωμένο' },
|
||||
]
|
||||
|
||||
const STATUS_LABELS_MOCK = {
|
||||
free: 'ΕΛΕΥΘΕΡΟ',
|
||||
open: 'ΑΝΟΙΧΤΟ',
|
||||
mine: 'ΔΙΚΟ ΜΟΥ',
|
||||
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
|
||||
paid: 'ΠΛΗΡΩΜΕΝΟ',
|
||||
}
|
||||
|
||||
// Quick-suggest palettes per slot type
|
||||
const QUICK_SWATCHES = {
|
||||
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
|
||||
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
|
||||
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
|
||||
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
|
||||
}
|
||||
|
||||
// ─── Color picker modal ──────────────────────────────────────────────────────
|
||||
|
||||
// Parse any css colour string into { hex, alpha }.
|
||||
// Handles: #rrggbb, #rgb, rgba(r,g,b,a), rgb(r,g,b)
|
||||
function parseColour(v) {
|
||||
if (!v) return { hex: '#ffffff', alpha: 1 }
|
||||
const s = v.trim()
|
||||
// rgba / rgb
|
||||
const rgbaMatch = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/)
|
||||
if (rgbaMatch) {
|
||||
const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, '0')
|
||||
const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, '0')
|
||||
const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, '0')
|
||||
const a = rgbaMatch[4] != null ? parseFloat(rgbaMatch[4]) : 1
|
||||
return { hex: `#${r}${g}${b}`, alpha: Math.min(1, Math.max(0, a)) }
|
||||
}
|
||||
// #rgb shorthand
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(s)) {
|
||||
const [, r, g, b] = s
|
||||
return { hex: `#${r}${r}${g}${g}${b}${b}`, alpha: 1 }
|
||||
}
|
||||
// #rrggbb
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(s)) return { hex: s, alpha: 1 }
|
||||
return { hex: '#ffffff', alpha: 1 }
|
||||
}
|
||||
|
||||
function buildColour(hex, alpha) {
|
||||
if (alpha >= 1) return hex
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r},${g},${b},${alpha.toFixed(2)})`
|
||||
}
|
||||
|
||||
function ColourPickerModal({ value, onClose, onChange, slot }) {
|
||||
const parsed = parseColour(value)
|
||||
const [hex, setHex] = useState(parsed.hex)
|
||||
const [alpha, setAlpha] = useState(parsed.alpha)
|
||||
|
||||
// keep parent in sync whenever hex or alpha changes
|
||||
useEffect(() => { onChange(buildColour(hex, alpha)) }, [hex, alpha])
|
||||
|
||||
function commitSwatch(v) {
|
||||
const p = parseColour(v)
|
||||
setHex(p.hex)
|
||||
setAlpha(p.alpha)
|
||||
}
|
||||
|
||||
const preview = buildColour(hex, alpha)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff', borderRadius: 20, padding: 28, width: '100%', maxWidth: 400,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>Επιλογή Χρώματος</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{SLOTS.find(s => s.key === slot)?.label}</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 22, cursor: 'pointer', color: '#6b7280', lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Preview swatch — checkerboard behind so alpha is visible */}
|
||||
<div style={{
|
||||
width: '100%', height: 56, borderRadius: 12, marginBottom: 20,
|
||||
border: '1px solid #e5e7eb', overflow: 'hidden', position: 'relative',
|
||||
backgroundImage: 'linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)',
|
||||
backgroundSize: '12px 12px',
|
||||
backgroundPosition: '0 0,0 6px,6px -6px,-6px 0',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, background: preview,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontFamily: 'monospace', color: alpha > 0.5 ? '#fff' : '#374151',
|
||||
textShadow: alpha > 0.5 ? '0 1px 3px rgba(0,0,0,0.5)' : 'none',
|
||||
}}>
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colour picker + hex input */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 8 }}>Χρώμα</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={hex}
|
||||
onChange={e => setHex(e.target.value)}
|
||||
style={{ width: 48, height: 40, borderRadius: 8, border: '1px solid #e5e7eb', cursor: 'pointer', padding: 2, flexShrink: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={hex}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
setHex(v)
|
||||
}}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
flex: 1, height: 40, borderRadius: 8, border: '1px solid #e5e7eb',
|
||||
padding: '0 12px', fontSize: 13, fontFamily: 'monospace', color: '#111827',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Opacity slider — always visible */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>Διαφάνεια</div>
|
||||
<div style={{ fontSize: 12, fontFamily: 'monospace', color: '#6b7280' }}>{Math.round(alpha * 100)}%</div>
|
||||
</div>
|
||||
{/* Gradient track so you can see what you're dragging */}
|
||||
<div style={{
|
||||
position: 'relative', height: 28,
|
||||
background: `linear-gradient(to right, transparent, ${hex})`,
|
||||
borderRadius: 8, border: '1px solid #e5e7eb',
|
||||
backgroundImage: `linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%),linear-gradient(to right,transparent,${hex})`,
|
||||
backgroundSize: '10px 10px,10px 10px,10px 10px,10px 10px,100% 100%',
|
||||
backgroundPosition: '0 0,0 5px,5px -5px,-5px 0,0 0',
|
||||
}}>
|
||||
<input
|
||||
type="range"
|
||||
min={0} max={1} step={0.01}
|
||||
value={alpha}
|
||||
onChange={e => setAlpha(parseFloat(e.target.value))}
|
||||
style={{
|
||||
position: 'absolute', inset: 0, width: '100%', height: '100%',
|
||||
opacity: 0, cursor: 'pointer', margin: 0,
|
||||
}}
|
||||
/>
|
||||
{/* thumb indicator */}
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', transform: 'translate(-50%,-50%)',
|
||||
left: `${alpha * 100}%`,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: preview, border: '2px solid #fff',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick swatches */}
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 8 }}>Γρήγορη επιλογή</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{(QUICK_SWATCHES[slot] || []).map(c => {
|
||||
const p = parseColour(c)
|
||||
const built = buildColour(p.hex, p.alpha)
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
title={c}
|
||||
onClick={() => commitSwatch(c)}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
backgroundImage: `linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)`,
|
||||
backgroundSize: '8px 8px',
|
||||
backgroundPosition: '0 0,0 4px,4px -4px,-4px 0',
|
||||
position: 'relative', overflow: 'hidden',
|
||||
border: built === preview ? '3px solid #3758c9' : '2px solid #e5e7eb',
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, background: c }} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid #f3f4f6', display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1, height: 40, borderRadius: 10, border: '1px solid #e5e7eb',
|
||||
background: '#f9fafb', fontSize: 14, fontWeight: 600, cursor: 'pointer', color: '#374151',
|
||||
}}
|
||||
>Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Single colour slot row ──────────────────────────────────────────────────
|
||||
|
||||
function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0' }}>
|
||||
<button
|
||||
onClick={() => onOpen(mode, status, slotKey, value)}
|
||||
style={{
|
||||
width: 44, height: 28, borderRadius: 8, background: value,
|
||||
border: '1.5px solid #e5e7eb', cursor: 'pointer', flexShrink: 0,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', fontFamily: 'monospace', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Mini mock table card (for preview) ──────────────────────────────────────
|
||||
|
||||
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: 90, borderRadius: 12, background: cfg.cardBg,
|
||||
position: 'relative', flexShrink: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Table name + group */}
|
||||
<div style={{ position: 'absolute', top: 8, left: 10, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<span style={{
|
||||
fontSize: 17, fontWeight: 800, color: cfg.nameText,
|
||||
lineHeight: 1, letterSpacing: -0.5,
|
||||
}}>{mockName}</span>
|
||||
<span style={{
|
||||
fontSize: 7, fontWeight: 600, letterSpacing: 0.8,
|
||||
color: cfg.nameText + '80',
|
||||
textTransform: 'uppercase',
|
||||
}}>{groupName}</span>
|
||||
</div>
|
||||
{/* Status badge — tight equal padding on all sides */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 7, left: 7,
|
||||
background: cfg.badgeBg,
|
||||
borderRadius: 4, padding: '2px 5px',
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
<span style={{ fontSize: 7, fontWeight: 700, color: cfg.badgeText, whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Preview panel (6 mock cards per theme) ──────────────────────────────────
|
||||
|
||||
function PreviewPanel({ colours, mode }) {
|
||||
const isDark = mode === 'dark'
|
||||
const panelBg = isDark ? '#0d1520' : '#f1f5f9'
|
||||
const panelLabel = isDark ? '🌙 Προεπισκόπηση σκοτεινού θέματος' : '☀️ Προεπισκόπηση φωτεινού θέματος'
|
||||
const labelCol = isDark ? '#94a3b8' : '#64748b'
|
||||
|
||||
const mockCards = [
|
||||
{ status: 'free', name: 'TABLE 1', group: 'ΜΕΣΑ' },
|
||||
{ status: 'open', name: 'TABLE 2', group: 'ΜΕΣΑ' },
|
||||
{ status: 'mine', name: 'TABLE 3', group: 'ΜΕΣΑ' },
|
||||
{ status: 'partially_paid', name: 'TABLE 4', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
|
||||
{ status: 'paid', name: 'TABLE 5', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
|
||||
{ status: 'free', name: 'TABLE 6', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: panelBg, borderRadius: 16, padding: 16,
|
||||
border: '1px solid ' + (isDark ? '#253245' : '#cbd5e1'),
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: labelCol, marginBottom: 12, letterSpacing: 0.3 }}>
|
||||
{panelLabel}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
|
||||
{mockCards.map((mc, i) => (
|
||||
<MockCard
|
||||
key={i}
|
||||
cfg={colours[mode][mc.status]}
|
||||
label={STATUS_LABELS_MOCK[mc.status]}
|
||||
mockName={mc.name}
|
||||
groupName={mc.group}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Status block (one status, showing all 4 slots) ──────────────────────────
|
||||
|
||||
function StatusBlock({ mode, status, label, colours, onOpen }) {
|
||||
const cfg = colours[mode][status]
|
||||
return (
|
||||
<div style={{ background: '#f9fafb', borderRadius: 12, padding: '14px 16px', border: '1px solid #f0f0f0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<div style={{ width: 88, flexShrink: 0 }}>
|
||||
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Πατήστε ένα χρώμα για επεξεργασία</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, borderTop: '1px solid #ebebeb', paddingTop: 8 }}>
|
||||
{SLOTS.map(slot => (
|
||||
<ColourSlotRow
|
||||
key={slot.key}
|
||||
mode={mode}
|
||||
status={status}
|
||||
slotKey={slot.key}
|
||||
label={slot.label}
|
||||
value={cfg[slot.key]}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Mode section (light or dark) ────────────────────────────────────────────
|
||||
|
||||
function ModeSection({ mode, colours, onOpen }) {
|
||||
const label = mode === 'light' ? '☀️ Φωτεινό θέμα' : '🌙 Σκοτεινό θέμα'
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#111827', marginBottom: 14 }}>{label}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{STATUSES.map(s => (
|
||||
<StatusBlock
|
||||
key={s.key}
|
||||
mode={mode}
|
||||
status={s.key}
|
||||
label={s.label}
|
||||
colours={colours}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ColoursTab() {
|
||||
const [colours, setColours] = useState(DEFAULT_COLOURS)
|
||||
const [modal, setModal] = useState(null) // { mode, status, slot, value }
|
||||
const [saving, setSaving] = useState(false)
|
||||
const saveTimer = useRef(null)
|
||||
|
||||
// Load from backend on mount
|
||||
useEffect(() => {
|
||||
client.get('/api/settings/').then(r => {
|
||||
const raw = r.data?.['ui.table_colours']?.value
|
||||
if (raw) {
|
||||
try { setColours(JSON.parse(raw)) } catch {}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Debounced save to backend — 600 ms after last change
|
||||
const saveToBackend = useCallback((next) => {
|
||||
clearTimeout(saveTimer.current)
|
||||
setSaving(true)
|
||||
saveTimer.current = setTimeout(() => {
|
||||
client.put('/api/settings/ui.table_colours', { value: JSON.stringify(next) })
|
||||
.then(() => setSaving(false))
|
||||
.catch(() => { toast.error('Σφάλμα αποθήκευσης χρωμάτων'); setSaving(false) })
|
||||
}, 600)
|
||||
}, [])
|
||||
|
||||
function setColour(mode, status, slot, value) {
|
||||
setColours(prev => {
|
||||
const next = {
|
||||
...prev,
|
||||
[mode]: {
|
||||
...prev[mode],
|
||||
[status]: { ...prev[mode][status], [slot]: value },
|
||||
},
|
||||
}
|
||||
saveToBackend(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function openModal(mode, status, slot, value) {
|
||||
setModal({ mode, status, slot, value })
|
||||
}
|
||||
|
||||
function handleChange(value) {
|
||||
setColour(modal.mode, modal.status, modal.slot, value)
|
||||
setModal(m => ({ ...m, value }))
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (window.confirm('Επαναφορά όλων των χρωμάτων στις προεπιλογές; Δεν μπορεί να αναιρεθεί.')) {
|
||||
setColours(DEFAULT_COLOURS)
|
||||
saveToBackend(DEFAULT_COLOURS)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
{saving && <p style={{ fontSize: 12, color: '#9ca3af', marginBottom: 16 }}>Αποθήκευση…</p>}
|
||||
|
||||
{/* Live previews side by side */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 32 }}>
|
||||
<PreviewPanel colours={colours} mode="light" />
|
||||
<PreviewPanel colours={colours} mode="dark" />
|
||||
</div>
|
||||
|
||||
{/* Light + Dark mode settings */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 32 }}>
|
||||
<ModeSection mode="light" colours={colours} onOpen={openModal} />
|
||||
<ModeSection mode="dark" colours={colours} onOpen={openModal} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset all button at bottom */}
|
||||
<div style={{ marginTop: 32, paddingTop: 24, borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
height: 40, padding: '0 20px', borderRadius: 10,
|
||||
border: '1.5px solid #fca5a5', background: '#fff5f5',
|
||||
color: '#dc2626', fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Επαναφορά προεπιλογών
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Colour picker modal */}
|
||||
{modal && (
|
||||
<ColourPickerModal
|
||||
value={modal.value}
|
||||
slot={modal.slot}
|
||||
onClose={() => setModal(null)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user