diff --git a/docker-compose.yml b/docker-compose.yml
index 21c7a17..f95c2cc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,7 @@ services:
- ./local_backend/pos.db:/app/pos.db
- ./local_backend/license_state.json:/app/license_state.json
- ./logo.png:/app/logo.png:ro
+ - ./data/product_images:/app/data/product_images
extra_hosts:
- "host.docker.internal:host-gateway"
diff --git a/local_backend/main.py b/local_backend/main.py
index f226679..1358747 100644
--- a/local_backend/main.py
+++ b/local_backend/main.py
@@ -1,6 +1,8 @@
+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
@@ -16,9 +18,34 @@ import models.order # noqa: F401
from routers import auth, tables, products, orders, waiters, reports, system
+def _run_migrations():
+ """Apply additive schema changes that create_all won't handle."""
+ from sqlalchemy import text
+ with engine.connect() as conn:
+ # Add extra_cost to product_ingredients if missing
+ try:
+ conn.execute(text("ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0"))
+ conn.commit()
+ except Exception:
+ pass
+ # Add image_url to products if missing
+ try:
+ conn.execute(text("ALTER TABLE products ADD COLUMN image_url VARCHAR"))
+ conn.commit()
+ except Exception:
+ pass
+ # Add group_id to tables if missing (added in Phase 3 table groups)
+ try:
+ conn.execute(text("ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)"))
+ conn.commit()
+ except Exception:
+ pass
+
+
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
+ _run_migrations()
sync_task = await start_cloud_sync()
yield
sync_task.cancel()
@@ -34,6 +61,11 @@ app.add_middleware(
)
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")
+
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"])
diff --git a/local_backend/models/product.py b/local_backend/models/product.py
index ced7274..5c77efc 100644
--- a/local_backend/models/product.py
+++ b/local_backend/models/product.py
@@ -23,11 +23,13 @@ class Product(Base):
base_price = Column(Float, nullable=False)
is_available = Column(Boolean, default=True, nullable=False)
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
+ image_url = Column(String, nullable=True)
category = relationship("Category", back_populates="products")
printer_zone = relationship("Printer", back_populates="products")
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")
@@ -48,5 +50,28 @@ class ProductIngredient(Base):
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)
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)
+
+ 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)
+
+ set = relationship("ProductPreferenceSet", back_populates="choices")
diff --git a/local_backend/models/table.py b/local_backend/models/table.py
index dfbfa32..01e9a42 100644
--- a/local_backend/models/table.py
+++ b/local_backend/models/table.py
@@ -1,16 +1,28 @@
-from sqlalchemy import Column, Integer, String, Boolean, Float
+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)
+ sort_order = Column(Integer, default=0)
+
+ tables = relationship("Table", back_populates="group")
+
+
class Table(Base):
__tablename__ = "tables"
id = Column(Integer, primary_key=True, index=True)
- number = Column(Integer, unique=True, nullable=False)
+ number = Column(Integer, nullable=False)
label = Column(String, nullable=True)
+ group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
floor_x = Column(Float, nullable=True)
floor_y = Column(Float, nullable=True)
+ group = relationship("TableGroup", back_populates="tables")
orders = relationship("Order", back_populates="table")
diff --git a/local_backend/routers/auth.py b/local_backend/routers/auth.py
index f4868d7..a7d569a 100644
--- a/local_backend/routers/auth.py
+++ b/local_backend/routers/auth.py
@@ -62,3 +62,8 @@ def refresh(token: str, db: Session = Depends(get_db)):
def logout(token: str):
_blacklisted_tokens.add(token)
return {"status": "logged out"}
+
+
+@router.get("/me", response_model=UserOut)
+def me(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
+ return user
diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py
index a4177d5..24a4b11 100644
--- a/local_backend/routers/orders.py
+++ b/local_backend/routers/orders.py
@@ -109,13 +109,18 @@ def add_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")
+ # Calculate extra cost from selected options
+ 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, # price snapshot
- selected_options=json.dumps(item_in.selected_options) if item_in.selected_options else None,
+ unit_price=product.base_price + extra_cost, # price snapshot with options
+ 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,
)
diff --git a/local_backend/routers/products.py b/local_backend/routers/products.py
index 2dbae69..10665f3 100644
--- a/local_backend/routers/products.py
+++ b/local_backend/routers/products.py
@@ -1,20 +1,53 @@
-from fastapi import APIRouter, Depends, HTTPException, status
+import os
+import uuid
+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, ProductIngredient
+from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.user import User
from schemas.product import (
ProductCreate, ProductUpdate, ProductOut,
- CategoryCreate, CategoryUpdate, CategoryOut,
+ CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
+ PreferenceSetCreate,
)
from routers.deps import get_current_user, require_manager
router = APIRouter()
+IMAGE_DIR = "/app/data/product_images"
-# ── Categories ───────────────────────────────────────────────────────────────
+
+def _replace_options(db, product, options):
+ for opt in product.options:
+ db.delete(opt)
+ db.flush()
+ for opt in options:
+ db.add(ProductOption(product_id=product.id, **opt.model_dump()))
+
+
+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:
+ new_set = ProductPreferenceSet(product_id=product.id, name=ps.name)
+ db.add(new_set)
+ db.flush()
+ for ch in ps.choices:
+ db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump()))
+
+
+# ── Categories ────────────────────────────────────────────────────────────────
@router.get("/categories", response_model=List[CategoryOut])
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
@@ -23,13 +56,23 @@ def list_categories(db: Session = Depends(get_db), user: User = Depends(get_curr
@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)):
- cat = Category(**body.model_dump())
+ max_order = db.query(Category).count()
+ cat = Category(name=body.name, color=body.color, sort_order=max_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/{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()
@@ -54,13 +97,16 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
# ── Products ──────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[ProductOut])
-def list_products(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
- return db.query(Product).filter(Product.is_available == True).all()
+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"):
+ q = q.filter(Product.is_available == True)
+ return q.all()
@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={"options", "ingredients"})
+ data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
product = Product(**data)
db.add(product)
db.flush()
@@ -68,6 +114,12 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
db.add(ProductOption(product_id=product.id, **opt.model_dump()))
for ing in body.ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
+ for ps in body.preference_sets:
+ new_set = ProductPreferenceSet(product_id=product.id, name=ps.name)
+ db.add(new_set)
+ db.flush()
+ for ch in ps.choices:
+ db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump()))
db.commit()
db.refresh(product)
return product
@@ -78,8 +130,45 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
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).items():
+ for field, value in body.model_dump(exclude_none=True, exclude={"options", "ingredients", "preference_sets"}).items():
setattr(product, field, value)
+ 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)
+
+ # Delete old image if exists
+ 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
diff --git a/local_backend/routers/tables.py b/local_backend/routers/tables.py
index b8435e7..942fc12 100644
--- a/local_backend/routers/tables.py
+++ b/local_backend/routers/tables.py
@@ -3,24 +3,72 @@ from sqlalchemy.orm import Session
from typing import List
from database import get_db
-from models.table import Table
+from models.table import Table, TableGroup
from models.order import Order
from models.user import User
-from schemas.table import TableCreate, TableUpdate, TableFloorplanUpdate, TableOut
+from schemas.table import (
+ TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
+ TableGroupCreate, TableGroupUpdate, TableGroupOut,
+ TableBatchCreate,
+)
from routers.deps import get_current_user, require_manager
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, 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 ────────────────────────────────────────────────────────────────────
+
@router.get("/", response_model=List[TableOut])
-def list_tables(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
- return db.query(Table).filter(Table.is_active == True).all()
+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)
+ return q.order_by(Table.group_id, Table.number).all()
@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)):
- if db.query(Table).filter(Table.number == body.number).first():
- raise HTTPException(status_code=400, detail="Table number already exists")
table = Table(**body.model_dump())
db.add(table)
db.commit()
@@ -28,6 +76,28 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
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")
+ created = []
+ for i in range(body.count):
+ n = body.start_number + i
+ table = Table(
+ number=n,
+ label=f"{body.name_prefix}{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()
@@ -41,11 +111,20 @@ def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db)
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
-def deactivate_table(table_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+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")
- table.is_active = False
+ if hard:
+ active_order = db.query(Order).filter(
+ Order.table_id == table_id,
+ Order.status.in_(["open", "partially_paid"])
+ ).first()
+ if active_order:
+ raise HTTPException(status_code=400, detail="Cannot delete table with active order")
+ db.delete(table)
+ else:
+ table.is_active = False
db.commit()
diff --git a/local_backend/schemas/order.py b/local_backend/schemas/order.py
index fdcc527..c9b8ff0 100644
--- a/local_backend/schemas/order.py
+++ b/local_backend/schemas/order.py
@@ -3,11 +3,18 @@ from datetime import datetime
from typing import Optional, List
+class SelectedOptionInput(BaseModel):
+ id: Optional[int] = None
+ name: Optional[str] = None
+ price_delta: Optional[float] = None
+ extra_cost: Optional[float] = None
+
+
class OrderItemInput(BaseModel):
product_id: int
quantity: int
- selected_options: Optional[List[int]] = None
- removed_ingredients: Optional[List[int]] = None
+ selected_options: Optional[List[SelectedOptionInput]] = None
+ removed_ingredients: Optional[List[str]] = None
notes: Optional[str] = None
diff --git a/local_backend/schemas/product.py b/local_backend/schemas/product.py
index 0ca4b12..86acf91 100644
--- a/local_backend/schemas/product.py
+++ b/local_backend/schemas/product.py
@@ -2,28 +2,34 @@ from pydantic import BaseModel
from typing import Optional, List
-class CategoryBase(BaseModel):
+class CategoryCreate(BaseModel):
name: str
color: Optional[str] = None
sort_order: int = 0
-class CategoryCreate(CategoryBase):
- pass
-
-
class CategoryUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
sort_order: Optional[int] = None
-class CategoryOut(CategoryBase):
+class CategoryOut(BaseModel):
id: int
+ name: str
+ color: Optional[str] = None
+ sort_order: int = 0
model_config = {"from_attributes": True}
+class CategoryReorderItem(BaseModel):
+ id: int
+ sort_order: int
+
+
+# ── Options ──────────────────────────────────────────────────────────────────
+
class ProductOptionBase(BaseModel):
name: str
extra_cost: float = 0.0
@@ -40,8 +46,11 @@ class ProductOptionOut(ProductOptionBase):
model_config = {"from_attributes": True}
+# ── Ingredients ───────────────────────────────────────────────────────────────
+
class ProductIngredientBase(BaseModel):
name: str
+ extra_cost: float = 0.0
class ProductIngredientCreate(ProductIngredientBase):
@@ -55,6 +64,42 @@ class ProductIngredientOut(ProductIngredientBase):
model_config = {"from_attributes": True}
+# ── Preferences ───────────────────────────────────────────────────────────────
+
+class PreferenceChoiceBase(BaseModel):
+ name: str
+ extra_cost: float = 0.0
+
+
+class PreferenceChoiceCreate(PreferenceChoiceBase):
+ pass
+
+
+class PreferenceChoiceOut(PreferenceChoiceBase):
+ id: int
+ set_id: int
+
+ model_config = {"from_attributes": True}
+
+
+class PreferenceSetBase(BaseModel):
+ name: str
+
+
+class PreferenceSetCreate(PreferenceSetBase):
+ choices: List[PreferenceChoiceCreate] = []
+
+
+class PreferenceSetOut(PreferenceSetBase):
+ id: int
+ product_id: int
+ choices: List[PreferenceChoiceOut] = []
+
+ model_config = {"from_attributes": True}
+
+
+# ── Products ──────────────────────────────────────────────────────────────────
+
class ProductBase(BaseModel):
name: str
category_id: Optional[int] = None
@@ -66,6 +111,7 @@ class ProductBase(BaseModel):
class ProductCreate(ProductBase):
options: List[ProductOptionCreate] = []
ingredients: List[ProductIngredientCreate] = []
+ preference_sets: List[PreferenceSetCreate] = []
class ProductUpdate(BaseModel):
@@ -74,11 +120,16 @@ class ProductUpdate(BaseModel):
base_price: Optional[float] = None
is_available: Optional[bool] = None
printer_zone_id: Optional[int] = None
+ options: Optional[List[ProductOptionCreate]] = None
+ ingredients: Optional[List[ProductIngredientCreate]] = None
+ preference_sets: Optional[List[PreferenceSetCreate]] = None
class ProductOut(ProductBase):
id: int
options: List[ProductOptionOut] = []
ingredients: List[ProductIngredientOut] = []
+ preference_sets: List[PreferenceSetOut] = []
+ image_url: Optional[str] = None
model_config = {"from_attributes": True}
diff --git a/local_backend/schemas/table.py b/local_backend/schemas/table.py
index 7b2a989..73735ed 100644
--- a/local_backend/schemas/table.py
+++ b/local_backend/schemas/table.py
@@ -1,10 +1,27 @@
from pydantic import BaseModel
-from typing import Optional
+from typing import Optional, List
+
+
+class TableGroupCreate(BaseModel):
+ name: str
+
+
+class TableGroupUpdate(BaseModel):
+ name: Optional[str] = None
+
+
+class TableGroupOut(BaseModel):
+ id: int
+ name: str
+ sort_order: int = 0
+
+ model_config = {"from_attributes": True}
class TableBase(BaseModel):
number: int
label: Optional[str] = None
+ group_id: Optional[int] = None
is_active: bool = True
@@ -12,9 +29,17 @@ class TableCreate(TableBase):
pass
+class TableBatchCreate(BaseModel):
+ group_id: Optional[int] = None
+ count: int
+ name_prefix: str # e.g. "Out-" → Out-1, Out-2 ...
+ start_number: int = 1
+
+
class TableUpdate(BaseModel):
number: Optional[int] = None
label: Optional[str] = None
+ group_id: Optional[int] = None
is_active: Optional[bool] = None
@@ -27,5 +52,6 @@ class TableOut(TableBase):
id: int
floor_x: Optional[float] = None
floor_y: Optional[float] = None
+ group: Optional[TableGroupOut] = None
model_config = {"from_attributes": True}
diff --git a/manager_dashboard/src/pages/ProductsPage.jsx b/manager_dashboard/src/pages/ProductsPage.jsx
index 984ba88..6b0fdc4 100644
--- a/manager_dashboard/src/pages/ProductsPage.jsx
+++ b/manager_dashboard/src/pages/ProductsPage.jsx
@@ -4,30 +4,54 @@ import toast from 'react-hot-toast'
import client from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
-const EMPTY_PRODUCT = { name: '', category_id: '', base_price: '', is_available: true, printer_zone_id: '', options: [], ingredients: [] }
+const EMPTY_PRODUCT = {
+ name: '', category_id: '', base_price: '', is_available: true,
+ printer_zone_id: '', options: [], ingredients: [], preference_sets: [],
+}
+
+// ── Category colour swatch ────────────────────────────────────────────────────
+const COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
+
+function ColorPicker({ value, onChange }) {
+ return (
+
+ {COLORS.map(c => (
+
+ )
+}
export default function ProductsPage() {
const qc = useQueryClient()
const [selectedCat, setSelectedCat] = useState(null)
- const [editProduct, setEditProduct] = useState(null) // null | 'new' | product object
+ const [editProduct, setEditProduct] = useState(null)
const [editCat, setEditCat] = useState(null)
- const [confirmDelete, setConfirmDelete] = useState(null) // { type: 'product'|'category', id }
+ const [confirmDelete, setConfirmDelete] = useState(null)
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: () => client.get('/api/products/categories').then(r => r.data),
})
+ // Manager fetches ALL products including unavailable ones
const { data: allProducts = [] } = useQuery({
queryKey: ['products-all'],
- queryFn: () => client.get('/api/products/').then(r => r.data),
+ queryFn: () => client.get('/api/products/?all=true').then(r => r.data),
})
- const { data: printers = [] } = useQuery({
- queryKey: ['printers'],
- queryFn: () => client.get('/api/system/status').then(r => r.data.printers ?? []),
+ const { data: statusData } = useQuery({
+ queryKey: ['system-status'],
+ queryFn: () => client.get('/api/system/status').then(r => r.data),
staleTime: 60_000,
})
+ const printers = statusData?.printers ?? []
const products = selectedCat
? allProducts.filter(p => p.category_id === selectedCat)
@@ -39,10 +63,9 @@ export default function ProductsPage() {
}
const saveCat = useMutation({
- mutationFn: (body) =>
- editCat?.id
- ? client.put(`/api/products/categories/${editCat.id}`, body)
- : client.post('/api/products/categories', body),
+ mutationFn: (body) => editCat?.id
+ ? client.put(`/api/products/categories/${editCat.id}`, body)
+ : client.post('/api/products/categories', body),
onSuccess: () => { toast.success('Κατηγορία αποθηκεύτηκε'); setEditCat(null); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
@@ -53,18 +76,22 @@ export default function ProductsPage() {
onError: () => toast.error('Σφάλμα'),
})
+ const reorderCats = useMutation({
+ mutationFn: (items) => client.put('/api/products/categories/reorder', items),
+ onSuccess: () => invalidate(),
+ })
+
const saveProduct = useMutation({
- mutationFn: (body) =>
- editProduct?.id
- ? client.put(`/api/products/${editProduct.id}`, body)
- : client.post('/api/products/', body),
+ mutationFn: (body) => editProduct?.id
+ ? client.put(`/api/products/${editProduct.id}`, body)
+ : client.post('/api/products/', body),
onSuccess: () => { toast.success('Προϊόν αποθηκεύτηκε'); setEditProduct(null); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const toggleAvail = useMutation({
mutationFn: ({ id, is_available }) => client.put(`/api/products/${id}`, { is_available }),
- onSuccess: () => { invalidate() },
+ onSuccess: () => invalidate(),
onError: () => toast.error('Σφάλμα'),
})
@@ -74,6 +101,19 @@ export default function ProductsPage() {
onError: () => toast.error('Σφάλμα'),
})
+ function moveCat(cat, dir) {
+ const sorted = [...categories].sort((a, b) => a.sort_order - b.sort_order)
+ const idx = sorted.findIndex(c => c.id === cat.id)
+ const swapIdx = idx + dir
+ if (swapIdx < 0 || swapIdx >= sorted.length) return
+ const updates = sorted.map((c, i) => {
+ if (i === idx) return { id: c.id, sort_order: sorted[swapIdx].sort_order }
+ if (i === swapIdx) return { id: c.id, sort_order: sorted[idx].sort_order }
+ return { id: c.id, sort_order: c.sort_order }
+ })
+ reorderCats.mutate(updates)
+ }
+
function handleConfirmDelete() {
if (!confirmDelete) return
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
@@ -83,7 +123,7 @@ export default function ProductsPage() {
return (
{/* Left: Categories */}
-