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:
2026-05-20 14:04:38 +03:00
commit 8ba8c95ecd
209 changed files with 48017 additions and 0 deletions

14
.env.example Normal file
View 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
View 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
Readme Normal file
View File

0
certs/.gitkeep Normal file
View File

20
docker-compose.dev.yml Normal file
View 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
View 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
View 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

View File

@@ -0,0 +1,7 @@
pos.db
license_state.json
__pycache__
*.pyc
*.pyo
.env
data/

10
local_backend/Dockerfile Normal file
View 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
View 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
View 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
View 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"])

View File

View 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)

View File

View 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")

View 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")

View 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")

View 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")

View 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")

View 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")

View 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])

View 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")

View 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")

View 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")

View 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
View 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 14: 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()

View 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

View File

View 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

View 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}

View 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."}

View 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

View 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": []})

View 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]

View 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"}

View 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()

File diff suppressed because it is too large Load Diff

View 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}

View 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)

View 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

View 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",
},
)

View 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"}

View 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

View 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()

View File

View 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

View 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",
),
]

View 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

View 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]

View 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}

View 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}

View 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}

View 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}

View 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

View 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

View 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}

View 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
View 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()

View File

View 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

View 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

View 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

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,3 @@
node_modules
dist
.env

View 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;"]

View 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>

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}

View 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>
)
}

View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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,
}
}

View 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

View 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

View 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

View 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

View 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

View 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;
}
}

View 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>
)
}

View 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>
)

View 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>
)
}

View 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>
)
}

View 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}>&nbsp;&nbsp; {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>
)
}

File diff suppressed because it is too large Load Diff

View 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>
)
}

File diff suppressed because it is too large Load Diff

View 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>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>
)
}

View 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">Τραπέζια &amp; Ζώνες</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="Εισαγωγή Τραπεζιών &amp; Ζωνών"
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>
)
}

View 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