From 24a029a8cc17c498b59d805f0919a3b6e2c32173 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 20 Apr 2026 18:39:51 +0300 Subject: [PATCH] Fix order saving, isMyOrder, blocked waiters, options pricing; add preferences, table groups, product images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - OrderItemInput accepts option objects {id,name,price_delta} instead of int IDs - extra_cost from selected options added to unit_price snapshot - GET /api/products/?all=true for manager (includes unavailable) - PUT /api/products/{id} now replaces options, ingredients, preference_sets - POST /api/products/{id}/image — persistent image upload to /app/data/product_images - New models: ProductPreferenceSet, ProductPreferenceChoice, TableGroup - tables: group_id FK, hard delete (?hard=true), batch create POST /api/tables/batch - GET /api/tables/groups + POST/PUT/DELETE groups endpoints - POST /api/auth/me endpoint for token rehydration - Auto-migration on startup for new columns PWA: - AuthRehydrator: fetches /auth/me on load so isMyOrder works after page reload - 401 response force-logs out (covers blocked waiters) - ItemOptionsModal: uses extra_cost correctly, shows preferences as radio buttons Manager: - ProductsPage: shows unavailable products greyed out, category color picker + reorder, full option/ingredient/preference editing, image upload - TablesPage: table groups, auto-increment, deactivate vs hard delete, batch add --- docker-compose.yml | 1 + local_backend/main.py | 32 ++ local_backend/models/product.py | 25 ++ local_backend/models/table.py | 16 +- local_backend/routers/auth.py | 5 + local_backend/routers/orders.py | 9 +- local_backend/routers/products.py | 107 ++++++- local_backend/routers/tables.py | 95 +++++- local_backend/schemas/order.py | 11 +- local_backend/schemas/product.py | 63 +++- local_backend/schemas/table.py | 28 +- manager_dashboard/src/pages/ProductsPage.jsx | 286 ++++++++++++------ manager_dashboard/src/pages/TablesPage.jsx | 255 ++++++++++++---- waiter_pwa/src/App.jsx | 15 + waiter_pwa/src/api/client.js | 5 + .../src/components/ItemOptionsModal.jsx | 45 ++- 16 files changed, 826 insertions(+), 172 deletions(-) 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 */} -