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

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